DEV Community

clearpath-data
clearpath-data

Posted on

New US private funding rounds, daily, from Form D notices in Python

When a US company raises money privately, under Regulation D for example, it usually files a short notice called Form D with the U.S. Securities and Exchange Commission. The notice is due within 15 days of the first sale, and it's public. It names the issuer, its state and industry, the size of the offering, how much has been sold so far and which exemption it relies on.

That makes Form D a handy, free signal for questions like which companies in my sector started raising money this week? Deal sourcing, market maps, local business reporting and sales research all start there.

The filings sit in the SEC's public EDGAR archive, which has no API key and no sign-up. This post builds a small script that:

  1. finds the latest published business day;
  2. lists that day's Form D and Form D/A (amendment) filings;
  3. reads each notice and keeps the offering facts, skipping pooled investment funds;
  4. writes a CSV, and runs once a day.

Python 3.10+ and requests. The XML parsing uses the standard library.

Step 0: be a polite client

The archive is free to use, with two fair-access conditions from the SEC's Accessing EDGAR Data page:

  • Declare who you are in the User-Agent header: a name plus a contact email.
  • Keep it under 10 requests per second in total. Going over can get your IP address temporarily blocked.

The script reads the User-Agent from an environment variable so your email stays out of the code:

export SEC_USER_AGENT="Your Name your-email@example.com"
Enter fullscreen mode Exit fullscreen mode

It also spaces requests 0.25 s apart (4 per second at most) and backs off when the server says no:

# new_form_d.py  -- usage: python new_form_d.py [--date YYYY-MM-DD] [--limit N] [--include-funds]
import argparse
import csv
import datetime as dt
import os
import pathlib
import re
import time
import xml.etree.ElementTree as ET

import requests

ARCHIVES = "https://www.sec.gov/Archives/edgar"
# Fair access: say who you are (name + contact email), and stay well under 10 requests per second.
USER_AGENT = os.environ.get("SEC_USER_AGENT", "Your Name your-email@example.com")
MIN_INTERVAL = 0.25  # seconds between requests, so at most 4 per second

session = requests.Session()
session.headers.update({"User-Agent": USER_AGENT})
_last_request = 0.0


def get(url: str) -> requests.Response:
    """GET with a simple rate limit and a short back-off on 403/429/5xx."""
    global _last_request
    for attempt in range(3):
        wait = _last_request + MIN_INTERVAL - time.monotonic()
        if wait > 0:
            time.sleep(wait)
        _last_request = time.monotonic()
        r = session.get(url, timeout=30)
        # 403 can mean "slow down", or a User-Agent the site doesn't accept. Either way, back off.
        if r.status_code in (403, 429) or r.status_code >= 500:
            time.sleep(10 * (attempt + 1))
            continue
        r.raise_for_status()
        return r
    raise RuntimeError(f"Gave up on {url} (HTTP {r.status_code}). Check your User-Agent and request rate.")
Enter fullscreen mode Exit fullscreen mode

Step 1: which days are published, and what was filed

The archive has a daily index for every business day. Each quarter's folder has an index.json listing, and each day has a form.YYYYMMDD.idx file: a fixed-width text list of every filing that day, sorted by form type. Form D notices have the form type D; amendments are D/A.

Form Type   Company Name                                                  CIK
      Date Filed  File Name
---------------------------------------------------------------------------------
D                ASAL International                                            1770205     20261023    edgar/data/1770205/0001770205-26-000003.txt
D/A              ABS ALPHA LTD                                                 1359353     20261023    edgar/data/1359353/0001013594-26-001074.txt
Enter fullscreen mode Exit fullscreen mode

The last part of the file name is the filing's accession number, its unique ID. We need it, with the CIK (the filer's ID), to find the notice itself.

def published_days(year: int, quarter: int) -> list[dt.date]:
    """Business days in a quarter whose daily index has been published."""
    listing = get(f"{ARCHIVES}/daily-index/{year}/QTR{quarter}/index.json").json()
    days = set()
    for item in listing["directory"]["item"]:
        m = re.fullmatch(r"form\.(\d{8})\.idx", item["name"])
        if m:
            days.add(dt.datetime.strptime(m.group(1), "%Y%m%d").date())
    return sorted(days)


LINE = re.compile(r"^(D|D/A)\s+(.+?)\s+(\d+)\s+(\d{8})\s+edgar/data/\d+/(\d{10}-\d{2}-\d{6})\.txt\s*$")


def form_d_filings(day: dt.date) -> list[dict]:
    """Every Form D and D/A filed on `day`, from that day's form index."""
    quarter = (day.month - 1) // 3 + 1
    text = get(f"{ARCHIVES}/daily-index/{day.year}/QTR{quarter}/form.{day:%Y%m%d}.idx").text
    filings = []
    for line in text.splitlines():
        m = LINE.match(line)
        if m:
            form, company, cik, _, accession = m.groups()
            filings.append({"form": form, "company": company.strip(), "cik": cik, "accession": accession})
    return filings
Enter fullscreen mode Exit fullscreen mode

Step 2: read each Form D

Each Form D filed online has a machine-readable primary_doc.xml in the filing's folder: /Archives/edgar/data/<CIK>/<accession without dashes>/primary_doc.xml. The useful parts:

XML path What it is
primaryIssuer/entityName, issuerAddress/stateOrCountry Who is raising, and where its principal place of business is
offeringData/industryGroup/industryGroupType Industry, e.g. Biotechnology, Other Technology, Pooled Investment Fund
offeringData/federalExemptionsExclusions/item Exemptions: 06b = Rule 506(b), 06c = 506(c), 04 = Rule 504, 3C.x = Investment Company Act exclusions
offeringData/offeringSalesAmounts/totalOfferingAmount Offering size in USD, or the word Indefinite
offeringData/offeringSalesAmounts/totalAmountSold Amount sold so far
offeringData/typeOfFiling/dateOfFirstSale A date, or yetToOccur
relatedPersonsList/relatedPersonInfo Executive officers, directors and promoters, with names and addresses

The script keeps the offering facts and only counts the related persons. More on that below.

def text(node: ET.Element | None, path: str) -> str:
    found = node.find(path) if node is not None else None
    return (found.text or "").strip() if found is not None else ""


def read_offering(filing: dict) -> dict:
    """Fetch one filing's Form D document and keep the offering facts (no personal names)."""
    folder = filing["accession"].replace("-", "")
    url = f"{ARCHIVES}/data/{filing['cik']}/{folder}/primary_doc.xml"
    doc = ET.fromstring(get(url).content)
    issuer, offering = doc.find("primaryIssuer"), doc.find("offeringData")
    amounts = offering.find("offeringSalesAmounts")
    first_sale = text(offering, "typeOfFiling/dateOfFirstSale/value")
    if text(offering, "typeOfFiling/dateOfFirstSale/yetToOccur") == "true":
        first_sale = "yet to occur"
    return {
        "accession": filing["accession"],
        "form": filing["form"],
        "issuer": text(issuer, "entityName") or filing["company"],
        "state": text(issuer, "issuerAddress/stateOrCountry"),
        "entity_type": text(issuer, "entityType"),
        "industry": text(offering, "industryGroup/industryGroupType"),
        "exemptions": " ".join(e.text for e in offering.findall("federalExemptionsExclusions/item")),
        "first_sale": first_sale,
        "offering_usd": text(amounts, "totalOfferingAmount"),  # a number or "Indefinite"
        "sold_usd": text(amounts, "totalAmountSold"),
        "investors": text(offering, "investors/totalNumberAlreadyInvested"),
        "related_persons": len(doc.findall("relatedPersonsList/relatedPersonInfo")),
        "url": url,
    }
Enter fullscreen mode Exit fullscreen mode

Step 3: skip the funds, write the CSV

A large share of each day's Form D filings come from pooled investment funds (hedge, private equity and venture funds raising from their own investors). If you're after operating companies, drop them; --include-funds keeps them.

def main() -> None:
    p = argparse.ArgumentParser()
    p.add_argument("--date", type=dt.date.fromisoformat, help="filing date; default: latest published day")
    p.add_argument("--limit", type=int, default=0, help="read at most N filings (0 = all)")
    p.add_argument("--include-funds", action="store_true", help="keep pooled investment funds")
    p.add_argument("--out-dir", default="data")
    args = p.parse_args()

    day = args.date
    if day is None:
        today = dt.date.today()
        day = published_days(today.year, (today.month - 1) // 3 + 1)[-1]
    filings = form_d_filings(day)
    print(f"{day}: {len(filings)} Form D filings ({sum(f['form'] == 'D/A' for f in filings)} amendments)")

    rows, funds = [], 0
    for filing in filings[: args.limit or None]:
        row = read_offering(filing)
        if row["industry"] == "Pooled Investment Fund" and not args.include_funds:
            funds += 1
            continue
        rows.append(row)

    out = pathlib.Path(args.out_dir) / f"form-d-{day}.csv"
    out.parent.mkdir(parents=True, exist_ok=True)
    with out.open("w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=list(rows[0]) if rows else ["accession"])
        w.writeheader()
        w.writerows(rows)
    print(f"{len(rows)} offerings kept, {funds} pooled funds skipped -> {out}")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

A run for Friday 23 October 2026. --limit 15 reads only the first 15 notices in the index, which keeps this demo to 16 requests. Drop it to read the whole day, one request per notice:

$ python new_form_d.py --date 2026-10-23 --limit 15
2026-10-23: 331 Form D filings (143 amendments)
6 offerings kept, 9 pooled funds skipped -> data/form-d-2026-10-23.csv
Enter fullscreen mode Exit fullscreen mode

Even in this small slice, most notices were pooled funds. Some rows of the CSV (columns trimmed):

form,issuer,state,industry,exemptions,first_sale,offering_usd,sold_usd,investors
D,ASAL International,TX,Business Services,06b,yet to occur,Indefinite,0,0
D,Advanced Glass Holdings, Inc.,IL,Other,06b,2026-10-09,2000000,2000000,10
D,Ascoltare Holdings LLC,FL,Biotechnology,06b,2025-05-09,4300000,1700000,10
D,Atlas 2026, LLC,OH,Other Real Estate,06b,2026-09-21,4750000,4750000,22
Enter fullscreen mode Exit fullscreen mode

(The index lists issuers alphabetically, which is why they all start with "A". Names with commas are quoted in the real CSV; the quotes are dropped here for readability.)

A few things to read from this:

  • Ascoltare Holdings has sold 1,700,000 USD of a 4,300,000 USD offering. Its first sale was in May 2025, so this round has been open for a while.
  • Advanced Glass Holdings and Atlas 2026 report the whole offering as sold, from 10 and 22 investors.
  • ASAL International hasn't sold anything yet ("yet to occur"), and it didn't state a total, so the offering size is Indefinite.
  • Amendments (D/A, 143 of the 331 that day) come after the new notices in the index. An amendment can mean a bigger round, more money sold, or just a yearly update. Its previousAccessionNumber (in typeOfFiling/newOrAmendment) points to the filing it amends.

Step 4: run it every day

The default --date is the latest day in the index. The daily file is published after the business day closes, so running the next morning picks up the previous business day. A crontab line for 07:00 on weekdays:

0 7 * * 1-5 cd /home/me/form-d && SEC_USER_AGENT="Your Name your-email@example.com" /usr/bin/python3 new_form_d.py >> run.log 2>&1
Enter fullscreen mode Exit fullscreen mode

Each notice is one request, so a full day takes as many requests as there are filings, at 4 per second.

Things to watch

  • People's names. Form D lists the issuer's executive officers, directors and promoters, often with an address, and the signature block names the signer. The filing is public, but once you store or combine those names you're processing personal data, and privacy law (GDPR, CCPA and others) can still apply. The script keeps only a count. If you need names, keep only what you need, store them securely and delete them when you're done.
  • It's a notice, not an audit. The figures are what the issuer reported. The SEC doesn't review or approve a Form D, and a filing isn't proof that any money was raised.
  • "Indefinite" amounts. Some issuers don't state a total, so offering_usd is the text Indefinite, not a number. Convert with care.
  • 403 means slow down, or say who you are. A missing or unaccepted User-Agent gets a 403 page titled "Your Request Originates from an Undeclared Automated Tool". The same status is used for rate limiting. If you see it, check your User-Agent and slow down; don't retry in a tight loop.
  • Bulk history. For past quarters, the SEC also publishes Form D data sets as downloadable files, so you don't need one request per filing.

This article was written with the help of AI. All code was run against the live SEC EDGAR archive on 27 October 2026, and the output shown is real (trimmed where noted).

Top comments (0)