DEV Community

Daniel Oliveira
Daniel Oliveira

Posted on Edited on

Diff two 13F filings by CUSIP (and check an AI's "what did Berkshire buy?" answer)

A common question to put to an AI assistant is "what did this fund buy last quarter?", and the answer comes from Form 13F-HR. Equibles has an MCP server that answers it. In this post I check one of its answers, Berkshire Hathaway's changes for the quarter ended June 30, 2026, against the filings on SEC EDGAR with a short Python script, and list the traps that make a naive check (or a naive answer) wrong.

1. Ask through MCP

The server is at https://mcp.equibles.com/mcp. In ChatGPT or Claude, add it as a connector; in Claude Code it is one command:

claude mcp add --transport http equibles https://mcp.equibles.com/mcp
Enter fullscreen mode Exit fullscreen mode

Sign-in is OAuth, and the free tier allows 100 requests a day. Setup for other clients is in the MCP docs.

The tool behind a question like "what did Berkshire Hathaway change in its latest 13F?" is GetInstitutionQuarterlyActivity. For this post I called it directly with Berkshire's SEC CIK, 1067983. It compared the June 30, 2026 report with March 31, 2026 and returned these changes:

Bucket Ticker Prior shares New shares
Initiated DHI 0 3,564
Increased GOOGL 54,249,798 78,791,167
Increased GOOG 3,585,215 27,188,433
Increased DAL 39,809,456 57,320,000
Increased LEN 10,099,642 13,111,741
Increased NYT 15,146,535 15,700,000
Increased M 3,038,355 7,347,426
Increased LEN-B 237,703 298,117
Reduced BAC 513,624,165 483,394,015
Reduced DVA 30,100,585 28,880,209
Reduced KR 50,000,000 39,000,000
Reduced COF 7,150,000 3,000,000
Reduced NUE 3,907,075 1,857,752
Reduced ALLY 29,000,000 27,000,000
Exited STZ 632,890 0

Fifteen changes. Now the check.

2. Find the two filings on EDGAR

Every filer's history is in https://data.sec.gov/submissions/CIK0001067983.json. The two 13F-HR filings I need:

  • Quarter ended March 31, 2026: accession 0001193125-26-226661, filed May 15, 2026 (index)
  • Quarter ended June 30, 2026: accession 0001193125-26-352200, filed August 14, 2026 (index)

Each filing folder holds a primary_doc.xml cover page and an information table XML with one <infoTable> element per row. File names vary by filer agent, so the script finds the table by its root element, informationTable.

3. Diff them by CUSIP

The script below uses only the standard library. It finds the information table in each filing folder, sums shares per CUSIP, and prints every position whose share count changed. It also warns when a filing leaves positions out under confidential treatment (trap 6).

"""Diff two 13F-HR filings by CUSIP, straight from SEC EDGAR."""
import json
import sys
import urllib.request
import xml.etree.ElementTree as ET
from collections import defaultdict

# SEC asks automated clients to identify themselves.
HEADERS = {"User-Agent": "your-name your-email@example.com"}
NS = {"t": "http://www.sec.gov/edgar/document/thirteenf/informationtable"}


def get(url):
    req = urllib.request.Request(url, headers=HEADERS)
    with urllib.request.urlopen(req) as resp:
        return resp.read()


def local(tag):
    return tag.rsplit("}", 1)[-1]


def holdings(cik, accession):
    """Sum share counts per CUSIP across every row of the information table."""
    folder = f"https://www.sec.gov/Archives/edgar/data/{int(cik)}/{accession.replace('-', '')}"
    files = json.loads(get(f"{folder}/index.json"))["directory"]["item"]
    cover = ET.fromstring(get(f"{folder}/primary_doc.xml"))
    omitted = any(local(e.tag) == "isConfidentialOmitted" and e.text == "true" for e in cover.iter())
    # The information table is the XML whose root element is informationTable.
    root = next(doc for doc in (ET.fromstring(get(f"{folder}/{f['name']}"))
                                for f in files
                                if f["name"].endswith(".xml") and f["name"] != "primary_doc.xml")
                if local(doc.tag) == "informationTable")
    shares, names, rows = defaultdict(int), {}, 0
    for row in root.findall("t:infoTable", NS):
        rows += 1
        if row.findtext("t:putCall", default="", namespaces=NS):
            continue  # options are not share ownership
        if row.findtext("t:shrsOrPrnAmt/t:sshPrnamtType", namespaces=NS) != "SH":
            continue  # PRN rows are dollars of principal, not shares
        key = row.findtext("t:cusip", namespaces=NS)
        shares[key] += int(row.findtext("t:shrsOrPrnAmt/t:sshPrnamt", namespaces=NS))
        names[key] = row.findtext("t:nameOfIssuer", namespaces=NS)
    if omitted:
        print(f"WARNING: {accession} omits positions under confidential treatment; "
              "they arrive later in a 13F-HR/A")
    return shares, names, rows


def main(cik, prior_accession, latest_accession):
    before, old_names, rows_before = holdings(cik, prior_accession)
    after, new_names, rows_after = holdings(cik, latest_accession)
    names = {**old_names, **new_names}
    print(f"rows: {rows_before} -> {rows_after}, positions: {len(before)} -> {len(after)}")
    for key in sorted(set(before) | set(after), key=lambda k: (names[k], k)):
        old, new = before.get(key, 0), after.get(key, 0)
        if old == new:
            continue
        label = "NEW" if old == 0 else "EXIT" if new == 0 else "ADD" if new > old else "CUT"
        print(f"{label:4} {names[key][:26]:26} {key} {old:>13,} -> {new:>13,}")


if __name__ == "__main__":
    main(*sys.argv[1:4])
Enter fullscreen mode Exit fullscreen mode

Put your own name and email in the User-Agent, then run it with the CIK and the two accession numbers:

python3 diff_13f.py 1067983 0001193125-26-226661 0001193125-26-352200
Enter fullscreen mode Exit fullscreen mode

Output from my run of the revised script on October 9, 2026:

rows: 90 -> 89, positions: 29 -> 29
CUT  ALLY FINL INC              02005N100    29,000,000 ->    27,000,000
ADD  ALPHABET INC               02079K107     3,585,215 ->    27,188,433
ADD  ALPHABET INC               02079K305    54,249,798 ->    78,791,167
CUT  BANK OF AMER CORP          060505104   513,624,165 ->   483,394,015
CUT  CAPITAL ONE FINL CORP      14040H105     7,150,000 ->     3,000,000
EXIT CONSTELLATION BRANDS INC   21036P108       632,890 ->             0
NEW  D R HORTON INC             23331A109             0 ->         3,564
CUT  DAVITA INC                 23918K108    30,100,585 ->    28,880,209
ADD  DELTA AIR LINES INC        247361702    39,809,456 ->    57,320,000
CUT  KROGER CO                  501044101    50,000,000 ->    39,000,000
ADD  LENNAR CORP                526057104    10,099,642 ->    13,111,741
ADD  LENNAR CORP                526057302       237,703 ->       298,117
ADD  MACYS INC                  55616P104     3,038,355 ->     7,347,426
ADD  NEW YORK TIMES CO MTN BE   650111107    15,146,535 ->    15,700,000
CUT  NUCOR CORP                 670346105     3,907,075 ->     1,857,752
Enter fullscreen mode Exit fullscreen mode

All fifteen changes match the MCP table share for share, including the 3,564-share D.R. Horton position and both Alphabet classes. Neither filing prints a confidential-treatment warning.

4. Six traps the script handles (and an answer should too)

One position is many rows. The June filing has 89 rows but only 29 distinct CUSIPs. Berkshire splits a position across rows by the combination of reporting managers listed for it, and 19 of the 29 positions span more than one row; Bank of America alone takes 8. Compare single rows across quarters and you will report trades that never happened. Sum per CUSIP first.

Key on CUSIP, never on the issuer name. Issuer names are free text typed by the filer, and they drift. In the March filing the bank is BANK AMERICA CORP and the insurer is CHUBB LTD SWITZ; in June they are BANK OF AMER CORP and CHUBB LIMITED. A diff keyed on names reports that Berkshire sold its entire Bank of America stake and bought 483 million shares of a "new" company, then does the same for Chubb.

A change in value is not a trade. Each row carries a reported dollar value. Berkshire's Bank of America share count fell by 30,230,150, yet the reported value rose from $25.04 billion to $27.54 billion because the price went up. New York Times went the other way: 553,465 more shares, $169.5 million less value. Rank by share change when the question is what the fund did.

Share classes are separate lines. Alphabet Class A (02079K305) and Class C (02079K107) have different CUSIPs. Berkshire added to both, 24.5 million and 23.6 million shares, so "Berkshire added 24.5 million Alphabet shares" leaves out half the buying.

Size before headline. "Berkshire opened a position in D.R. Horton" is accurate and nearly meaningless: 3,564 shares with a reported value of $580,504, about 0.0002% of the $299.25 billion the filing reports in total. A good answer puts the size next to the label.

A filing can leave positions out on purpose. A manager can ask the SEC to keep a position confidential for a time. The filing's summary page then sets isConfidentialOmitted to true, and the position shows up months later in a 13F-HR/A marked NEW HOLDINGS. Berkshire's original filing for September 30, 2023 did this, and the amendment filed May 15, 2024 added 8,143,530 shares of Chubb. Run the script on that quarter and it prints the warning. A share-for-share match shows the tool read the filing correctly, not that the filing is the whole book, so for a "what did they buy" answer, check for an amendment and re-run once it lands.

One timing caveat applies to all of it: a 13F shows holdings on the last day of the quarter and can be filed up to 45 days later, so this filing describes June 30, 2026, not today. It also covers only long positions in 13(f) securities. Options appear as rows with a putCall field, which the script skips (the June filing has none). Rows whose sshPrnamtType is PRN count dollars of principal, such as convertible notes, not shares, so the script skips those too.

5. Make it routine

When an assistant answers a 13F question, ask it for the report date and the accession number, then run a check like this whenever the answer matters. On the Equibles side, the tool reference is in the holdings docs, the same data is available through the REST API for scheduled jobs, and Berkshire's filings are browsable on its institution page.


Updated October 9, 2026, after a reader's comment: the script now finds the information table by its root element, skips principal (PRN) rows and warns on confidential treatment, and trap 6 is new. The output above is from a re-run of the revised script.

Nothing here is investment advice.

Top comments (2)

Collapse
 
arhancanli profile image
Arhan Canli •

The part I would add to trap 5 is what a 13F leaves out on purpose. Berkshire has historically used confidential treatment for some positions, which are omitted from the original filing and only show up later in an amendment (13F-HR/A). So a clean share-for-share match between the MCP table and the original filing shows the tool read the document correctly, not that the list of changes is complete. For a "what did they buy" answer I would want the tool to say whether an amendment exists for the period, and to re-run once it does.

Two small things in the script for other filers. The information table is picked as the first .xml that isn't primary_doc.xml, which can grab the wrong file when a filing has more than one; matching on the root element is safer. And it sums sshPrnamt without reading sshPrnamtType, so a filer holding convertible notes would add dollars of principal (PRN) to share counts (SH) under the same CUSIP. Berkshire doesn't trigger either, so the run here can't show them.

Collapse
 
daniel_oliveira_4bd55d35d profile image
Daniel Oliveira •

Thanks, both script points were right, and I've updated the post: it now picks the information table by its root element and counts only SH rows, so PRN principal can't land in a share count. The re-run gives the same fifteen changes.

I added confidential treatment as a sixth trap. Berkshire's original filing for Q3 2023 set isConfidentialOmitted to true, and the NEW HOLDINGS amendment filed in May 2024 added the Chubb stake. The script now prints a warning when a filing omits positions.

Checking the Equibles side turned up a bug: the tools carry a confidential-treatment warning, but the importer read the flag from a cover-page field the SEC doesn't publish, so it never fired. I'm fixing it to read the summary page and adding the warning to the quarterly activity tool. A NEW HOLDINGS amendment is merged into the stored quarter, so a re-run after it lands returns the fuller book, but the answer doesn't say whether an amendment exists.

Disclosure: I build Equibles.