sifting/io
SEC Filings & Fundamentals
6 min readSiftingIO Team

Tracking institutional position changes across 13F filings

How to diff 13F-HR holdings between reporting cycles using SiftingIO, find new positions, exits, and meaningful size changes.

Tracking institutional position changes across 13F filings

Quarterly 13F-HR filings are one of the few windows into what large allocators actually own. Once you load them into a clean structure, you can ask the questions that matter: which positions are new this quarter, which ones got cut, and where conviction grew or shrank by enough basis points to bother noticing. The friction is rarely the analysis. It's the plumbing around it.

This post walks through reading 13F holdings from SiftingIO, comparing two reporting cycles for the same filer, and surfacing position changes in a form you can hand to a dashboard or a notebook.

What's in a 13F-HR#

Institutional investment managers with at least $100 million in qualifying assets file Form 13F-HR with the SEC within 45 days after each calendar quarter ends. The filing lists long positions in 13F-eligible securities at quarter-end. It is a snapshot, not a flow: you see what was held, not when it was bought or sold during the quarter.

A holdings row carries the issuer, the CUSIP, the share count, the reported market value (in thousands of USD), and a few flags for put or call positions and shared discretion. The structure is the same for every filer, which is the part that makes systematic comparison practical.

A few things worth keeping in mind. The reporting lag means the data is at least 45 days stale by the time it arrives. Short positions are not in there. And the dollar values are point-in-time market values at quarter-end, so a position that doubled in price without a single share traded will look like it grew.

Pulling holdings for one filer#

SiftingIO exposes institutional holdings under the fundamentals tree:

curl -H "X-API-Key: $SIFTING_KEY" \
     "https://api.sifting.io/v1/fnd/filers/{filer}/holdings?limit=200"

The {filer} path parameter is a zero-padded 10-digit CIK. The response is paginated; the default is 50 rows and the cap is 200, so a filer with several hundred positions will need to walk the cursor. Each page's meta.next_cursor is opaque, so just pass it back as ?cursor= until it comes back null.

Here is a small Python helper that pulls every holding for a filer at the most recent period and returns a flat list:

import os
import requests

BASE = "https://api.sifting.io"
HEADERS = {
    "X-API-Key": os.environ["SIFTING_KEY"],
    "Accept-Encoding": "gzip",
}

def fetch_holdings(filer_cik: str):
    rows, cursor = [], None
    while True:
        params = {"limit": 200}
        if cursor:
            params["cursor"] = cursor
        r = requests.get(
            f"{BASE}/v1/fnd/filers/{filer_cik}/holdings",
            headers=HEADERS,
            params=params,
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        rows.extend(body["data"])
        cursor = body["meta"].get("next_cursor")
        if not cursor:
            return rows

Accept-Encoding: gzip is the safe default for anything in /v1/fnd/* or /v1/hist/*. The heavy endpoints actually require it and return a 406 if it's missing, but adding it everywhere costs nothing and saves bandwidth on the routine calls too.

Diffing two reporting cycles#

A useful first cut is "what changed between this quarter and last." Load both cycles, key on CUSIP, and bucket each issuer into one of four states: newly opened, exited, increased, or decreased.

import pandas as pd

def diff_holdings(prev, curr):
    p = pd.DataFrame(prev).set_index("cusip")
    c = pd.DataFrame(curr).set_index("cusip")

    new = c.index.difference(p.index)
    exited = p.index.difference(c.index)
    common = c.index.intersection(p.index)

    delta = (c.loc[common, "shares"] - p.loc[common, "shares"]).rename("share_change")
    pct = (delta / p.loc[common, "shares"]).rename("pct_change")

    return {
        "new": c.loc[new][["issuer", "shares", "value_usd_thousands"]],
        "exited": p.loc[exited][["issuer", "shares", "value_usd_thousands"]],
        "changed": pd.concat([
            c.loc[common, ["issuer", "shares"]],
            delta,
            pct,
        ], axis=1).query("share_change != 0"),
    }

CUSIP, not ticker, is the right key. Tickers can change. CUSIPs are tied to the security, and the SEC's filing uses them as the canonical identifier. If you want the ticker back for display, resolve it once at the end against your reference table rather than joining on it during the diff.

For ranking, the most-watched signal is the change in position size relative to the filer's total portfolio. A filer who triples a 0.05% position is not doing the same thing as one who doubles a 4% position. Compute portfolio weight as value_usd_thousands / total_portfolio_value at each cycle and compare the weights rather than raw share counts.

Cross-filer clustering#

The next interesting question is which positions several large allocators are adding to at the same time. That's a cluster scan: run the same diff for a list of filers, group the changes by CUSIP, and count how many filers opened or grew the same position in the same cycle.

The pattern is exactly the same shape as the single-filer diff. The only added work is bookkeeping: associate each change with its filer CIK so you can attribute the cluster back to specific names. Throttle the loop with a small sleep between filers if you're walking dozens of CIKs on a paid tier; on the free tier, watch X-RateLimit-Remaining on every response and back off when it gets close to zero.

Common pitfalls#

A few things that catch people the first time they do this.

CUSIPs change after corporate actions. A spinoff or reorganization can issue a new CUSIP for what is effectively the same economic exposure. If a position "exited" along with a new one of similar size appearing the same quarter under a different issuer name, treat that pair as suspicious before reporting it as a sell.

Reported market value moves with price, not just with trading. A 30% drawdown in an issuer's price between two quarter-ends will shrink the dollar value of a held position by 30% even if the share count is identical. Always diff on share count for the question "did they trade," and on portfolio weight for "did their conviction change." Mixing the two leads to bad headlines.

Pagination is real. A large filer routinely has 500-plus positions. If you forget to walk meta.next_cursor, you'll silently work off the first 200 rows and your diff will show dozens of "exited" positions that the filer still holds. There is no error for this case, the response just stops, so the bug stays invisible until someone notices the numbers don't reconcile. Treat a non-null next_cursor as a loop condition, not an edge case.

Period alignment matters. Two filers can file for the same calendar quarter on different dates. A query without an explicit period filter may pull one filer's Q1 alongside another's Q4. Pin the reporting period when you're comparing across managers.

Wrapping up#

13F data is one of the cleaner public datasets once you accept its limits: stale by 45 days, longs only, point-in-time. Past those, the structure is regular enough to power a steady weekly job that tells you what changed and who's clustering on what. The hard part is the schema and the pagination, both of which the API handles so the diff stays a few dozen lines of pandas.

Read the docs for the full holdings response shape, filer search, and the related insider transaction endpoints.

Tags13finstitutional-holdingssec-filingsfundamentalspythonpandaspaginationquant-researchcusip
All postsLast updated May 30, 2026