← back to Stock Vshape Viewer

server.js

256 lines

#!/usr/bin/env node
// Zero-dependency server for the Stock V-Shape screener.
// Basic-auth gated. Endpoints:
//   /api/history?ticker=      live daily OHLCV (Stooq -> Yahoo)
//   /api/universe?minPrice=   NASDAQ screener + DJIA, filtered by price
//   /api/scan/start?...       start a background universe screen (returns job id)
//   /api/scan/status?job=     progress + matches
//   /api/options?ticker=      CBOE free options chain (parsed), for cheap far-dated screen
const http = require('http');
const fs = require('fs');
const path = require('path');
const SCREEN = require('./public/screen.js');

const USER = process.env.VIEWER_USER || 'admin';
const PASS = process.env.VIEWER_PASS || 'DW2024!';
const ROOT = path.join(__dirname, 'public');
const HCACHE = new Map(); const HTTL = 5 * 60 * 1000;      // history cache
const OCACHE = new Map(); const OTTL = 10 * 60 * 1000;     // options cache
let UNIV = null, UNIV_TS = 0; const UTTL = 30 * 60 * 1000; // universe cache
const JOBS = new Map();
const UA = { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', 'Accept': 'application/json' };

const DJIA = ['AAPL','AMGN','AXP','BA','CAT','CRM','CSCO','CVX','DIS','DOW','GS','HD','HON','IBM','JNJ','JPM','KO','MCD','MMM','MRK','MSFT','NKE','NVDA','PG','SHW','TRV','UNH','V','VZ','WMT'];
const MAJOR_ETFS = new Set(['SPY','QQQ','IWM','DIA','VOO','VTI','VEA','VWO','IVV','VUG','VTV','VIG','SCHD','SMH','SOXX','XLK','XLF','XLE','XLV','XLY','XLP','XLI','XLU','XLB','XLRE','XLC','ARKK','ARKG','XBI','IBB','GLD','SLV','TLT','HYG','LQD','GDX','KRE','ITB','XHB','VNQ','EEM','EFA','IEMG','QUAL','MTUM','TQQQ','SQQQ','SOXL']);

const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8' };
function unauthorized(res) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="stock-vshape"' }); res.end('Auth required'); }
function jsonRes(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); }

// ---------------- price history (Stooq -> Yahoo) ----------------
async function fromStooq(t) {
  const r = await fetch(`https://stooq.com/q/d/l/?s=${t.toLowerCase()}.us&i=d`, { headers: UA });
  if (!r.ok) throw new Error(`stooq ${r.status}`);
  const lines = (await r.text()).trim().split('\n');
  if (lines.length < 3 || !/^Date,Open/i.test(lines[0])) throw new Error('stooq bad');
  return lines.slice(1).map(l => { const [date, o, h, lo, c, v] = l.split(','); return { date, open: +o, high: +h, low: +lo, close: +c, vol: +v || 0 }; })
    .filter(d => isFinite(d.close) && d.close > 0).slice(-540);
}
async function fromYahoo(t) {
  const r = await fetch(`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(t)}?range=2y&interval=1d`, { headers: UA });
  if (!r.ok) throw new Error(`yahoo ${r.status}`);
  const res = (await r.json())?.chart?.result?.[0];
  if (!res || !res.timestamp) throw new Error('yahoo empty');
  const q = res.indicators.quote[0], out = [];
  for (let i = 0; i < res.timestamp.length; i++) { const c = q.close[i]; if (c == null) continue;
    out.push({ date: new Date(res.timestamp[i] * 1000).toISOString().slice(0, 10), open: q.open[i], high: q.high[i], low: q.low[i], close: c, vol: q.volume[i] || 0 }); }
  return out.slice(-540);
}
async function getHistory(t) {
  const hit = HCACHE.get(t); if (hit && Date.now() - hit.ts < HTTL) return hit.payload;
  let days, source, err = [];
  for (const [nm, fn] of [['stooq', fromStooq], ['yahoo', fromYahoo]]) { try { days = await fn(t); source = nm; break; } catch (e) { err.push(`${nm}:${e.message}`); } }
  if (!days || !days.length) throw new Error('all sources failed — ' + err.join(' | '));
  const payload = { ticker: t, source, asOf: new Date().toISOString(), count: days.length, days };
  HCACHE.set(t, { ts: Date.now(), payload }); return payload;
}

// ---------------- universe (NASDAQ screener + DJIA) ----------------
async function getUniverse(minPrice) {
  if (UNIV && Date.now() - UNIV_TS < UTTL) return UNIV.filter(u => u.price >= minPrice || u.cap === Infinity);
  const r = await fetch('https://api.nasdaq.com/api/screener/stocks?tableonly=true&limit=6000&offset=0&exchange=NASDAQ', { headers: { ...UA, 'Accept-Language': 'en-US,en;q=0.9' } });
  let rows = [];
  if (r.ok) { const j = await r.json(); rows = j?.data?.table?.rows || j?.data?.rows || []; }
  const seen = new Set(), list = [];
  for (const row of rows) {
    const sym = (row.symbol || '').trim().toUpperCase();
    if (!sym || /[\^./]/.test(sym) || seen.has(sym)) continue;
    const price = parseFloat(String(row.lastsale || '').replace(/[$,]/g, '')) || 0;
    const cap = parseFloat(String(row.marketCap || '').replace(/[$,]/g, '')) || 0;
    seen.add(sym); list.push({ ticker: sym, price, cap, name: row.name || sym });
  }
  for (const d of DJIA) if (!seen.has(d)) { seen.add(d); list.push({ ticker: d, price: 0, cap: Infinity, name: d + ' (DJIA)' }); }
  // major ETFs — added explicitly (the ETF screener ignores limit + omits SPY/QQQ);
  // price 0 here, real price comes from history during the scan.
  for (const e of MAJOR_ETFS) if (!seen.has(e)) { seen.add(e); list.push({ ticker: e, price: 0, cap: Infinity, name: e + ' (ETF)', isEtf: true }); }
  list.sort((a, b) => b.cap - a.cap);
  UNIV = list; UNIV_TS = Date.now();
  return list.filter(u => u.price >= minPrice || u.cap === Infinity);
}

// ---------------- options (CBOE free delayed chain) ----------------
async function getOptions(t) {
  const hit = OCACHE.get(t); if (hit && Date.now() - hit.ts < OTTL) return hit.payload;
  let data;
  for (const u of [`https://cdn.cboe.com/api/global/delayed_quotes/options/${t}.json`, `https://cdn.cboe.com/api/global/delayed_quotes/options/_${t}.json`]) {
    const r = await fetch(u, { headers: UA }); if (r.ok) { data = await r.json(); break; }
  }
  if (!data) throw new Error('cboe no chain');
  const under = data?.data?.current_price || 0;
  const chain = [];
  for (const o of (data?.data?.options || [])) {
    const p = SCREEN.parseOCC(o.option); if (!p) continue;
    chain.push({ exp: p.exp, type: p.type, strike: p.strike, bid: o.bid, ask: o.ask, last: o.last_trade_price, oi: o.open_interest, vol: o.volume, delta: o.delta });
  }
  const payload = { ticker: t, underlying: under, asOf: new Date().toISOString(), chain };
  OCACHE.set(t, { ts: Date.now(), payload }); return payload;
}

// ---------------- score backtest (built once at boot, cached) ----------------
let BT = null, BT_TS = 0, BT_BUILDING = false; const BT_TTL = 6 * 60 * 60 * 1000;
// backtest is SEGMENTED by market-cap tier — the score works on large-caps but inverts
// on speculative small-caps (yoloforever cycle 2 finding, Steve chose to surface it).
const BT_LARGE = ['NVDA','AAPL','MSFT','AMZN','GOOGL','META','TSLA','AVGO','JPM','BAC','WMT','XOM','CVX','UNH','LLY','JNJ','PG','KO','PEP','HD','MCD','NKE','DIS','NFLX','CRM','ORCL','AMD','INTC','CSCO','QCOM','TXN','IBM','GE','CAT','BA','MMM','HON','UPS','SPY','QQQ','IWM','XLK','XLF','XLE','SMH','ARKK'];
const BT_SMALL = ['SOFI','RIVN','AFRM','DKNG','HOOD','CVNA','CELH','TOST','IOT','CHWY','PATH','RUN','RKLB','IONQ','ACHR','FUBO','PLUG','LCID','RBLX','U'];
const BT_HORIZONS = [20, 60, 120];
const BT_BANDS = [[0, 19], [20, 39], [40, 59], [60, 79], [80, 100]];
async function buildBacktest() {
  if (BT_BUILDING) return; BT_BUILDING = true;
  try {
    const F = SCREEN.DEFAULTS, TF = 126, mean = a => a.reduce((x, y) => x + y, 0) / (a.length || 1);
    const segLists = { large: BT_LARGE, small: BT_SMALL };
    const rows = { large: { 20: [], 60: [], 120: [] }, small: { 20: [], 60: [], 120: [] } };
    const used = { large: 0, small: 0 };
    for (const seg of ['large', 'small']) {
      for (const t of segLists[seg]) {
        let full; try { full = (await getHistory(t)).days; } catch { continue; }
        if (!full || full.length < TF + 130) continue; used[seg]++;
        const closes = full.map(d => d.close), ma50 = SCREEN.sma(closes, 50), ma200 = SCREEN.sma(closes, 200);
        for (let tE = TF; tE < full.length - Math.max(...BT_HORIZONS); tE += 3) {
          const an = SCREEN.analyze(full.slice(tE - TF, tE), ma50.slice(tE - TF, tE), ma200.slice(tE - TF, tE), F);
          for (const H of BT_HORIZONS) { const fwd = full[tE + H].close / full[tE].close - 1; if (isFinite(fwd)) rows[seg][H].push({ s: an.buyScore, f: fwd }); }
        }
      }
    }
    const segStats = seg => BT_HORIZONS.map(H => {
      const all = rows[seg][H].map(r => r.f), base = mean(all);
      const bands = BT_BANDS.map(([lo, hi]) => { const f = rows[seg][H].filter(r => r.s >= lo && r.s <= hi).map(r => r.f);
        return { lo, hi, n: f.length, mret: f.length ? mean(f) : 0, win: f.length ? f.filter(x => x > 0).length / f.length : 0 }; });
      const pop = bands.filter(b => b.n >= 30); let corr = 0;
      if (pop.length >= 3) { const xs = pop.map((_, i) => i), ys = pop.map(b => b.mret), mx = mean(xs), my = mean(ys);
        const cov = mean(xs.map((x, i) => (x - mx) * (ys[i] - my))), sx = Math.sqrt(mean(xs.map(x => (x - mx) ** 2))), sy = Math.sqrt(mean(ys.map(y => (y - my) ** 2)));
        corr = sx && sy ? cov / (sx * sy) : 0; }
      return { H, baseline: base, bands, corr: +corr.toFixed(2) };
    });
    BT = { asOf: new Date().toISOString(),
      large: { tickers: used.large, days: rows.large[20].length, horizons: segStats('large') },
      small: { tickers: used.small, days: rows.small[20].length, horizons: segStats('small') } };
    BT_TS = Date.now();
  } catch (e) { /* leave BT null */ } finally { BT_BUILDING = false; }
}

// ---------------- universe scan job ----------------
function filtersFromQuery(p) {
  const num = (k, d) => p.has(k) ? +p.get(k) : d, bool = (k) => p.get(k) === 'true';
  return { weeksAgo: num('weeksAgo', 6), weeksTol: num('weeksTol', 3), minDip: num('minDip', 10),
    maxFromHigh: num('maxFromHigh', 60), minPrice: num('minPrice', 8), reqNewHigh: p.has('reqNewHigh') ? bool('reqNewHigh') : true,
    dipTo50: bool('dipTo50'), dipTo200: bool('dipTo200'), below50: bool('below50'), below200: bool('below200'),
    largeOnly: bool('largeOnly') };
}
async function runScan(job, filters, tfN, max, largeOnly) {
  let universe = await getUniverse(filters.minPrice);
  if (largeOnly) universe = universe.filter(u => u.cap >= 40e9); // validated large-cap tier (Infinity DJIA/major-ETF pass)
  universe = universe.slice(0, max);
  job.total = universe.length;
  let i = 0;
  async function worker() {
    while (i < universe.length && !job.abort) {
      const u = universe[i++];
      try {
        const { days: full } = await getHistory(u.ticker);
        if (full && full.length >= 60) {
          const closes = full.map(d => d.close);
          const ma50 = SCREEN.sma(closes, 50), ma200 = SCREEN.sma(closes, 200);
          const N = Math.min(tfN, full.length);
          const an = SCREEN.analyze(full.slice(-N), ma50.slice(-N), ma200.slice(-N), filters);
          if (an.C >= filters.minPrice && SCREEN.passFilter(an, filters)) {
            job.matches.push({ ticker: u.ticker, name: u.name, cap: u.cap === Infinity ? null : u.cap, price: +an.C.toFixed(2),
              drawdown: +an.drawdown.toFixed(1), weeksAgoActual: an.weeksAgoActual, rsi: an.rsi, volSurge: an.volSurge,
              d50: an.d50 == null ? null : +an.d50.toFixed(1), d200: an.d200 == null ? null : +an.d200.toFixed(1),
              zoneCls: an.zoneCls, zoneLabel: an.zoneLabel, buyScore: an.buyScore });
          }
        }
      } catch { /* skip a bad symbol */ }
      job.scanned++;
    }
  }
  await Promise.all(Array.from({ length: 16 }, worker));
  job.matches.sort((a, b) => b.buyScore - a.buyScore);
  job.done = true; job.finishedAt = Date.now();
}

// ---------------- HTTP ----------------
const server = http.createServer(async (req, res) => {
  const hdr = req.headers.authorization || ''; const [scheme, enc] = hdr.split(' ');
  if (scheme !== 'Basic' || !enc) return unauthorized(res);
  const [u, p] = Buffer.from(enc, 'base64').toString().split(':');
  if (u !== USER || p !== PASS) return unauthorized(res);

  const [pathname, query] = (req.url || '/').split('?');
  if (pathname === '/favicon.ico') { res.writeHead(204); return res.end(); } // no spurious 404
  const P = new URLSearchParams(query || '');
  const tick = () => (P.get('ticker') || '').toUpperCase().replace(/[^A-Z.\-]/g, '');

  try {
    if (pathname === '/api/history') {
      const t = tick(); if (!t) return jsonRes(res, 400, { error: 'ticker required' });
      return jsonRes(res, 200, await getHistory(t));
    }
    if (pathname === '/api/universe') {
      const list = await getUniverse(+(P.get('minPrice') || 8));
      return jsonRes(res, 200, { count: list.length, universe: list.slice(0, +(P.get('max') || 5000)) });
    }
    if (pathname === '/api/options') {
      const t = tick(); if (!t) return jsonRes(res, 400, { error: 'ticker required' });
      const o = await getOptions(t);
      const screened = SCREEN.screenOptions(o.chain, Date.now(), {
        min: +(P.get('min') || 0.05), max: +(P.get('max') || 0.15),
        minDays: Math.round((+(P.get('minMonths') || 3)) * 30.4), type: P.get('type') || 'both', underlying: o.underlying });
      return jsonRes(res, 200, { ticker: t, underlying: o.underlying, asOf: o.asOf, total: o.chain.length, count: screened.length, contracts: screened.slice(0, 60) });
    }
    if (pathname === '/api/cap') {
      const t = tick(); if (!t) return jsonRes(res, 400, { error: 'ticker required' });
      const u = (await getUniverse(0)).find(x => x.ticker === t);
      return jsonRes(res, 200, { ticker: t, cap: u && u.cap !== Infinity ? u.cap : null });
    }
    if (pathname === '/api/backtest') {
      if (BT && Date.now() - BT_TS < BT_TTL) return jsonRes(res, 200, { status: 'ready', ...BT });
      if (!BT_BUILDING) buildBacktest();
      return jsonRes(res, 200, { status: 'building' });
    }
    if (pathname === '/api/scan/start') {
      const filters = filtersFromQuery(P), tfN = +(P.get('tfN') || 126), max = Math.min(+(P.get('max') || 1500), 4200);
      const id = 'job' + Date.now().toString(36) + Math.floor(Math.random() * 1e4).toString(36);
      const job = { id, total: 0, scanned: 0, matches: [], done: false, startedAt: Date.now(), abort: false };
      JOBS.set(id, job);
      runScan(job, filters, tfN, max, filters.largeOnly).catch(e => { job.error = String(e.message || e); job.done = true; });
      // prune old jobs
      for (const [k, v] of JOBS) if (Date.now() - v.startedAt > 20 * 60 * 1000) JOBS.delete(k);
      return jsonRes(res, 200, { job: id, universe: job.total });
    }
    if (pathname === '/api/scan/status') {
      const job = JOBS.get(P.get('job')); if (!job) return jsonRes(res, 404, { error: 'no such job' });
      return jsonRes(res, 200, { total: job.total, scanned: job.scanned, done: job.done, error: job.error || null,
        matchCount: job.matches.length, elapsedMs: (job.finishedAt || Date.now()) - job.startedAt,
        matches: job.done ? job.matches : job.matches.slice(0, 60) });
    }
  } catch (e) { return jsonRes(res, 502, { error: String(e.message || e) }); }

  // static
  let rel = decodeURIComponent(pathname); if (rel === '/') rel = '/index.html';
  const fp = path.normalize(path.join(ROOT, rel));
  if (!fp.startsWith(ROOT)) { res.writeHead(403); return res.end('Forbidden'); }
  fs.readFile(fp, (err, buf) => {
    if (err) { res.writeHead(404); return res.end('Not found'); }
    res.writeHead(200, { 'Content-Type': MIME[path.extname(fp)] || 'application/octet-stream' }); res.end(buf);
  });
});

server.listen(process.env.PORT ? Number(process.env.PORT) : 0, () => {
  const { port } = server.address();
  console.log(`\n  📈  Stock V-Shape screener (LIVE + scan + options)`);
  console.log(`  →  http://localhost:${port}/   auth: ${USER} / ${PASS}`);
  console.log(`  →  history: Stooq/Yahoo · universe: NASDAQ+DJIA · options: CBOE (all free)\n`);
  setTimeout(() => buildBacktest(), 3000); // build the score backtest in the background after boot
});