Skip to content

Build S&P500 Heatmap Indicator #882

@bennycode

Description

@bennycode
  1. Pull the S&P 500 constituents.
  2. Get each stock’s intraday percent change.
  3. Compute: Breadth: % of constituents up on the day, Cap-weighted return: market-cap-weighted average return.
  4. Classify the day:
  5. Red if cap-weighted return < −0.30% and breadth < 45%
  6. Green if cap-weighted return > +0.30% and breadth > 55%
  7. Mixed otherwise
// sp500-breadth.ts
// npm i yahoo-finance2 cheerio node-fetch @types/node --save
// ts-node sp500-breadth.ts

import yf from "yahoo-finance2";
import fetch from "node-fetch";
import * as cheerio from "cheerio";

type Snap = {
  ticker: string;
  pctChange: number;   // intraday return as decimal
  marketCap: number;   // in USD
};

async function getSP500Tickers(): Promise<string[]> {
  const url = "https://en.wikipedia.org/wiki/List_of_S%26P_500_companies";
  const html = await (await fetch(url)).text();
  const $ = cheerio.load(html);

  // First table on the page holds the index members
  const tickers = new Set<string>();
  $("table.wikitable tbody tr").each((_i, tr) => {
    const sym = $(tr).find("td:nth-child(1)").text().trim();
    if (sym) {
      // Yahoo uses - instead of . for class shares like BRK-B
      const t = sym.replace(/\./g, "-");
      tickers.add(t);
    }
  });

  // Basic sanity filter
  return Array.from(tickers).filter(t => /^[A-Z\-]+$/.test(t));
}

function chunk<T>(arr: T[], n: number): T[][] {
  const out: T[][] = [];
  for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n));
  return out;
}

async function fetchSnapshots(tickers: string[]): Promise<Snap[]> {
  const snaps: Snap[] = [];
  const batches = chunk(tickers, 50);

  for (const batch of batches) {
    try {
      // yahoo-finance2 can fetch multiple quotes in one call
      const quotes = await yf.quote(batch);
      for (const q of quotes) {
        const last = q.regularMarketPrice as number | undefined;
        const prev = q.previousClose as number | undefined;
        const mcap = q.marketCap as number | undefined;

        if (
          typeof last === "number" &&
          typeof prev === "number" &&
          prev > 0 &&
          typeof mcap === "number" &&
          mcap > 0
        ) {
          snaps.push({
            ticker: q.symbol!,
            pctChange: (last - prev) / prev,
            marketCap: mcap,
          });
        }
      }
    } catch {
      // Continue on batch failure
      continue;
    }
  }
  return snaps;
}

function classify(breadth: number, capWeightedReturn: number): "green" | "red" | "mixed" {
  if (capWeightedReturn < -0.003 && breadth < 0.45) return "red";
  if (capWeightedReturn > 0.003 && breadth > 0.55) return "green";
  return "mixed";
}

async function main() {
  const tickers = await getSP500Tickers();
  if (!tickers.length) {
    console.error("Could not load S&P 500 tickers");
    process.exit(1);
  }

  const snaps = await fetchSnapshots(tickers);
  if (!snaps.length) {
    console.error("No snapshots retrieved");
    process.exit(1);
  }

  const advancers = snaps.filter(s => s.pctChange > 0).length;
  const breadth = advancers / snaps.length;

  const totalMcap = snaps.reduce((a, b) => a + b.marketCap, 0);
  const capWeightedReturn =
    snaps.reduce((a, b) => a + (b.marketCap / totalMcap) * b.pctChange, 0);

  const verdict = classify(breadth, capWeightedReturn);

  const pct = (x: number, d = 2) => (x * 100).toFixed(d);

  const result = {
    sp500_constituents_used: snaps.length,
    breadth_up_pct: Number(pct(breadth, 1)),
    cap_weighted_return_pct: Number(pct(capWeightedReturn, 2)),
    verdict,
  };

  console.log(JSON.stringify(result, null, 2));
}

main().catch(err => {
  console.error(err);
  process.exit(1);
});

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions