← back to Commercialrealestate

scripts/fetch-edgar-reit-deals.js

101 lines

// fetch-edgar-reit-deals.js — RESELLABLE institutional deal-flow from SEC EDGAR (public, $0, no key).
//
// Doctrine (docs/SOURCING.md): resell only public-record data. Public real-estate companies + REITs are
// LEGALLY REQUIRED to disclose material property acquisitions to the SEC — free, public, resellable, and
// it catches the big institutional LLC/entity deals that dodge the county-assessor reassessment (the gap
// in derive-sale-prices). Strategy: (1) enumerate REITs by SIC 6798, (2) EDGAR full-text-search for
// acquisition 8-Ks in a window, (3) keep hits whose CIK is a REIT, (4) emit a linked filing feed.
// The $ + property extraction from each 8-K exhibit is a follow-on step (LLM-parse) — this ships the
// harness + metadata feed. SEC rule: descriptive User-Agent, <=10 req/s.
//   node scripts/fetch-edgar-reit-deals.js [days]     (default 120)
'use strict';
const fs = require('fs');
const path = require('path');
const UA = 'DesignerWallcoverings-research steve@designerwallcoverings.com';
const DAYS = +(process.argv[2] || 120);
const ROOT = path.join(__dirname, '..');
const sleep = ms => new Promise(r => setTimeout(r, ms));
const iso = d => d.toISOString().slice(0, 10);

async function get(url, asJson, tries = 3) {          // retry-then-skip: SEC 500s intermittently
  for (let i = 0; i < tries; i++) {
    try {
      const r = await fetch(url, { headers: { 'User-Agent': UA, 'Accept-Encoding': 'gzip, deflate' } });
      await sleep(140);                                // stay under SEC's 10 req/s
      if ((r.status === 500 || r.status === 429) && i < tries - 1) { await sleep(700); continue; }
      if (!r.ok) throw new Error(url + ' -> ' + r.status);
      return asJson ? r.json() : r.text();
    } catch (e) { if (i < tries - 1) { await sleep(700); continue; } throw e; }
  }
}

// 1. Enumerate REIT CIKs (SIC 6798) from browse-edgar atom. The atom's company names are a SEC bug
// ("ARRAY(0x...)"), but <cik> is clean — we only need the CIK SET for filtering; real names come from
// the FTS display_names. Source: SEC EDGAR company search, SIC=6798 (public).
async function reitCiks() {
  const set = new Set();
  for (let start = 0; start < 1500; start += 100) {
    let atom; try { atom = await get(`https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&SIC=6798&type=&dateb=&owner=include&count=100&start=${start}&output=atom`); }
    catch (e) { break; }
    const ciks = atom.match(/<cik>(\d+)<\/cik>/gi) || [];
    if (!ciks.length) break;
    for (const c of ciks) { const m = c.match(/(\d+)/); if (m) set.add(String(+m[1])); }
    process.stderr.write(`  REIT enum: ${set.size} so far\r`);
  }
  return set;
}

// 2. EDGAR full-text search for acquisition 8-Ks in the window.
async function acquisitionFilings(startdt, enddt) {
  const phrases = ['%22completed+the+acquisition%22', '%22acquired+the+property%22', '%22purchase+of+the+property%22', '%22completed+the+purchase%22'];
  const hits = [];
  for (const q of phrases) {
    let from = 0;
    for (let page = 0; page < 10; page++) {   // FTS caps ~100/page, 10 pages plenty for a window
      let d; try { d = await get(`https://efts.sec.gov/LATEST/search-index?q=${q}&forms=8-K&startdt=${startdt}&enddt=${enddt}&from=${from}`, true); }
      catch (e) { process.stderr.write(`  FTS skip (${q}): ${e.message.slice(-8)}\n`); break; }   // one bad phrase never kills the run
      const arr = (d.hits && d.hits.hits) || [];
      if (!arr.length) break;
      for (const x of arr) {
        const s = x._source || {};
        const cik = String(+(((s.display_names || [''])[0].match(/CIK\s*(\d+)/) || [])[1] || 0));
        hits.push({ id: x._id, cik, display: (s.display_names || [])[0] || '', date: s.file_date, form: (s.root_forms || s.file_type || '') });
      }
      from += arr.length;
    }
  }
  return hits;
}

(async () => {
  const end = new Date(process.env.GEN_TS ? Date.parse(process.env.GEN_TS) : Date.parse('2026-07-31'));
  const start = new Date(end.getTime() - DAYS * 86400000);
  process.stderr.write(`EDGAR REIT deal-flow: ${iso(start)} .. ${iso(end)}\n`);

  const reits = await reitCiks();
  process.stderr.write(`\nREITs (SIC 6798): ${reits.size}\n`);
  const all = await acquisitionFilings(iso(start), iso(end));
  // dedupe by filing id, keep REIT-only
  const seen = new Set(), deals = [];
  for (const h of all) {
    if (seen.has(h.id)) continue; seen.add(h.id);
    if (!reits.has(h.cik)) continue;
    const acc = h.id.split(':')[0].replace(/-/g, ''); const file = h.id.split(':')[1] || '';
    // Cody-gate fix (2026-07-31): FTS matches earnings releases/supplements that merely MENTION a past
    // acquisition. Those aren't acquisition filings — drop them so the feed isn't padded with them.
    if (/earning|supplement|quarterly|[-_]er[-_.]|q[1-4]x?20\d\d|x?er\d|bodrep/i.test(file)) continue;
    deals.push({
      company: (h.display.split('  (')[0] || h.display).trim(), cik: h.cik, ticker: (h.display.match(/\(([A-Z.\-]{1,6})\)/) || [])[1] || null,
      form: h.form, filed: h.date,
      filing_url: `https://www.sec.gov/Archives/edgar/data/${h.cik}/${acc}/${file}`,
      source: 'SEC EDGAR 8-K (public, resellable)', price: null, property: null, extract_status: 'pending-llm-parse'
    });
  }
  deals.sort((a, b) => (b.filed || '').localeCompare(a.filed || ''));
  const out = { generated: process.env.GEN_TS || iso(end), source: 'SEC EDGAR full-text search (SIC 6798 REITs, acquisition 8-Ks)', window_days: DAYS, reit_universe: reits.size, count: deals.length, deals };
  fs.writeFileSync(path.join(ROOT, 'data', 'edgar-reit-deals.json'), JSON.stringify(out, null, 2));
  process.stderr.write(`\nREIT acquisition filings (last ${DAYS}d): ${deals.length}\n`);
  deals.slice(0, 8).forEach(d => process.stderr.write(`  ${d.filed}  ${(d.company || '').slice(0, 40).padEnd(40)} ${d.ticker || ''}\n`));
  console.log(JSON.stringify({ reit_universe: reits.size, deals: deals.length, cost: '$0 (SEC public data)' }));
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });