رفتن به محتوا
همه اسناد کتابخانه

پاک‌سازی دارایی‌های SEC 13F برای تحلیل ازدحام نهادی

کد یادگیری ماشین برای معامله‌گری

خلاصه

این دفترچه داده‌های فصلی و انبوه دارایی‌های SEC 13F را برای بررسی مالکیت نهادی، رتبه‌بندی مدیران و شبکه‌های مالکیت مشترک پردازش می‌کند. با استفاده از تاریخ ثبت، آخرین گزارش هر مدیر را انتخاب می‌کند، اوراق بهادار را بر اساس CUSIP و نه متن نام ناشر تجمیع می‌کند و گزارش‌هایی را که میانه ارزش اعلام‌شده به‌ازای هر سهمشان خارج از دامنه معقول است غربال می‌کند. بررسی سازگاری قیمت از رابطه میان ارزش گزارش‌شده و شمار سهام استفاده می‌کند، بدون آنکه فرض کند کدام فیلد خطای واحد دارد. تحلیل، گستره مالکیت را از اندازه موقعیت گزارش‌شده متمایز می‌کند و اثر آستانه‌های مدیران مشترک بر تراکم گراف را بررسی می‌کند.

دفترچه تأکید می‌کند داده‌های 13F ناقص‌اند: گزارش‌ها موقعیت‌های فروش و بسیاری از طبقات دارایی را حذف می‌کنند و دارایی‌های گزارش‌شده با تأخیر نسبت به پایان فصل منتشر می‌شوند. گستره مالکیت می‌تواند نماینده‌ای برای ازدحام باشد، اما موقعیت‌های کوچک و بزرگ را یکسان می‌شمارد. اگر گراف‌های مالکیت مشترک از سهام پراکنده در مالکیت و مدیران بزرگ ساخته شوند، ممکن است تقریباً کاملاً متصل شوند و یال‌هایشان بی‌اطلاع‌کننده شود. داده‌های انبوه فصلی برای تحلیل مقطعی مناسب‌اند؛ بررسی واکنش به افشاهای تازه مستلزم دسترسی به گزارش‌ها هنگام انتشار عمومی آن‌هاست.

ایده‌های کلیدی

  • برای شناسایی اوراق بهادار از CUSIP استفاده کنید، چون نام ناشران را گزارش‌دهندگان وارد می‌کنند و املای آن‌ها یکدست نیست.
  • جدیدترین گزارش یک مدیر را بر اساس تاریخ ثبت انتخاب کنید، نه شماره دسترسی.
  • گزارش‌های ناسازگار درونی را با قیمت ضمنی حاصل از ارزش و شمار سهام گزارش‌شده غربال کنید و آنچه غربال حذف می‌کند را اعلام کنید.
  • گستره مالکیت تعداد مدیران دارنده سهم را می‌سنجد، اما اندازه موقعیت را نادیده می‌گیرد و فقط نماینده‌ای برای ازدحام است.
  • تراکم گراف را گزارش کنید، زیرا آستانه‌های مالکیت مشترک می‌توانند تقریباً همه سهام پراکنده در مالکیت را به هم متصل کنند.
  • داده‌های انبوه 13F با تأخیر منتشر می‌شوند و موقعیت‌های فروش و دارایی‌های خارج از دامنه گزارش را حذف می‌کنند.

برچسب‌ها

متن کامل
# 10_institutional_holdings_13f.py


```py
# ---
# jupyter:
#   jupytext:
#     cell_metadata_filter: tags,-all
#     text_representation:
#       extension: .py
#       format_name: percent
#       format_version: '1.3'
#       jupytext_version: 1.19.3
#   kernelspec:
#     display_name: Python 3 (ipykernel)
#     language: python
#     name: python3
# ---

# %% [markdown]
# # Institutional Holdings from SEC 13F Bulk Data
#
# **Chapter 4: Fundamental and Alternative Data**
# **Docker image**: `ml4t`
# **Section Reference**: Section 4.1 (The Point-in-Time Pipeline)
#
# ## Purpose
#
# Notebook 02 read one manager's Form 13F through a library. This one reads all of them. The SEC
# publishes every quarter's filings as a single archive: several million positions from around
# seven thousand managers, which is the whole institutional cross-section of the US equity market
# in one file, free.
#
# Working at that scale changes what the exercise is about. A single filing can be read and
# sanity-checked by eye; seven thousand cannot, and among them are filers who transposed a
# column, reported in the wrong unit, or spelled a company's name in a way no other filer used.
# Most of this notebook is the screening that has to happen before an aggregate over the file
# means anything: every one of those errors reaches a ranking intact, and none of them announces
# itself.
#
# ## Learning Objectives
#
# After completing this notebook, you will be able to:
#
# - Load a quarter of the SEC's bulk 13F archive and identify the columns that make a position
#   row unique.
# - Select one filing per manager per quarter when the archive holds originals, amendments and
#   two report periods at once.
# - Detect filings whose reported values are in the wrong unit, using a quantity the filing
#   itself makes checkable, and account for what screening them out removes.
# - Aggregate positions across managers on an identifier rather than on a name.
# - Measure how widely a stock is held and read that as a crowding proxy with its limits stated.
# - Build the two edge sets a holdings graph needs, and say which artifact a downstream notebook
#   should actually read.
#
# ## Prerequisites
#
# One quarter of the bulk archive, about 80 MB:
#
# ```bash
# python data/equities/positioning/13f_download.py --mode bulk --quarters 2024Q3
# ```
#
# ## Cross-References
#
# - **Upstream**: `data/equities/positioning/13f_download.py --mode bulk` (SEC quarterly data sets)
# - **Related**: [`02_sec_filing_explorer`](02_sec_filing_explorer.ipynb) (one manager's 13F through a library)
# - **Downstream**: `22_rag_financial_research/07_institutional_holdings_graph.py`, which reads
#   the canonical edge artifact through `load_13f_edges` rather than rebuilding it
#
# **Data source**: https://www.sec.gov/data-research/sec-markets-data/form-13f-data-sets

# %%
"""Institutional Holdings from SEC 13F Bulk Data - analyze top managers and ownership networks."""

import plotly.express as px
import polars as pl

from data import load_13f_bulk_holdings
from utils.style import COLORS, show_plotly_with_alt

# %% [markdown]
# The two screening bounds below are the settings that decide which filings are believed. They
# bracket the price of a share of anything a 13F can report: a stock trading under a dollar is
# not in a 13F portfolio at a material size, and nothing trades above ten thousand dollars a
# share except a handful of names that do not move a total. A filing whose positions imply a
# price outside that bracket has reported its values in a different unit from everyone else.

# %% tags=["parameters"]
QUARTER = "2024Q3"  # SEC filing-window label; a Q3 window spans 1 September to 30 November
TOP_N = 500  # managers carried into the holdings analysis, ranked by reported value
MIN_IMPLIED_PRICE = 1.0  # a filing implying less than this per share is screened out
MAX_IMPLIED_PRICE = 10_000.0  # and so is one implying more
MIN_SHARED_MANAGERS = 5  # managers two stocks must share before they get a co-ownership edge
CO_OWNERSHIP_UNIVERSE = 500  # stocks entering the pairwise co-ownership pass
THRESHOLD_GRID = [5, 50, 100, 150, 200, 250, 300, 350]  # thresholds the edge density is measured at

# %% [markdown]
# ## 1. The archive
#
# The downloader parses the SEC's quarterly zip once and writes one parquet per quarter. Each row
# is one position in one filing.
#
# | Column | Meaning |
# | --- | --- |
# | `cik` | The manager's Central Index Key, which identifies the filer permanently |
# | `accession_no` | The filing's own identifier; a manager may have several in one window |
# | `cusip` | The security's identifier, and the only reliable key for a holding |
# | `issuer` | The company name **as that filer typed it** |
# | `value_thousands` | The reported dollar value of the position |
# | `shares` | The reported number of shares |
# | `filing_date` | The date the filing reached EDGAR |
# | `company_name` | The manager's name as it appears on the cover page |

# %%
holdings = load_13f_bulk_holdings(QUARTER)

print(f"Positions: {len(holdings):,}")
print(f"Filings: {holdings['accession_no'].n_unique():,}")
print(f"Managers by CIK: {holdings['cik'].n_unique():,}")
print(f"Securities by CUSIP: {holdings['cusip'].n_unique():,}")
print(f"Filing window: {holdings['filing_date'].min()} to {holdings['filing_date'].max()}")

# %% [markdown]
# ### A name is not a key
#
# `issuer` is free text that each filer types, so the same security arrives under many spellings.
# Anything that groups on it will split one company into several, and the effect is worst on the
# most widely held names, which is exactly where an ownership analysis looks.

# %%
spellings = (
    holdings.group_by("cusip")
    .agg(pl.col("issuer").n_unique().alias("distinct_spellings"))
    .sort("distinct_spellings", descending=True)
)
print(f"Securities with more than one spelling: {(spellings['distinct_spellings'] > 1).sum():,}")
print(f"Most spellings of a single security: {spellings['distinct_spellings'].max()}")
holdings.filter(pl.col("cusip") == spellings["cusip"][0]).select("issuer").unique().head(6)

# %% [markdown]
# ## 2. Screening the filings
#
# ### One filing per manager
#
# A filing window catches originals, amendments, and occasionally two report periods, so a
# manager can appear several times. The one to keep is the most recent, and *most recent* means
# the latest filing date. An accession number looks sortable and is not: its first block is the
# identifier of the filing agent that submitted the document, so ordering by it ranks agents
# rather than dates.

# %%
filings = (
    holdings.group_by("accession_no", "cik", "company_name", "filing_date")
    .agg(
        pl.col("value_thousands").sum().alias("reported_value"),
        pl.col("cusip").n_unique().alias("positions"),
    )
    .sort(["cik", "filing_date", "accession_no"], descending=[False, True, True])
)
latest_filings = filings.group_by("cik", maintain_order=True).first()

print(f"Filings in the window: {len(filings):,}")
print(f"After keeping the latest per manager: {len(latest_filings):,}")
repeat_filers = filings.group_by("cik").len().filter(pl.col("len") > 1)
print(f"Managers with more than one filing: {len(repeat_filers):,}")
print(f"Filings those managers account for: {int(repeat_filers['len'].sum()):,}")

# %% [markdown]
# ### Filings whose numbers do not go together
#
# The SEC changed the value field from thousands of dollars to whole dollars, and the column name
# in this archive still carries the old convention. Filers did not all move at once, and share
# counts are misreported too, so a filing can be wrong in either of its two numeric columns.
#
# Which of them is wrong is not determinable from the file, and it does not need to be. The two
# columns constrain each other: value divided by shares is a price per share, and a price has a
# knowable range whatever units the two sides were meant to be in. A filing whose positions imply
# a price outside that range is internally inconsistent, and that is enough to set it aside. The
# median across a filing's positions is what makes the test robust to one bad row in an otherwise
# sound filing.

# %%
implied_prices = (
    holdings.filter(pl.col("shares") > 0)
    .with_columns(implied_price=pl.col("value_thousands") / pl.col("shares"))
    .group_by("accession_no")
    .agg(pl.col("implied_price").median().alias("median_implied_price"))
)
screened = latest_filings.join(implied_prices, on="accession_no", how="left").with_columns(
    believable=pl.col("median_implied_price").is_between(MIN_IMPLIED_PRICE, MAX_IMPLIED_PRICE)
)

total_value = screened["reported_value"].sum()
kept = screened.filter(pl.col("believable"))
too_high = screened.filter(pl.col("median_implied_price") > MAX_IMPLIED_PRICE)
too_low = screened.filter(pl.col("median_implied_price") < MIN_IMPLIED_PRICE)
no_price = screened.filter(pl.col("median_implied_price").is_null())

print(f"Filings kept: {len(kept):,} of {len(screened):,}")
print(
    f"  implying more than ${MAX_IMPLIED_PRICE:,.0f} a share: {len(too_high):,}, carrying ${too_high['reported_value'].sum() / 1e12:.2f}T"
)
print(
    f"  implying less than ${MIN_IMPLIED_PRICE:,.0f} a share: {len(too_low):,}, carrying ${too_low['reported_value'].sum() / 1e12:.2f}T"
)
print(
    f"  reporting no shares at all: {len(no_price):,}, carrying ${no_price['reported_value'].sum() / 1e12:.2f}T"
)
print(f"Total reported value before screening: ${total_value / 1e12:.2f}T")
screened.filter(~pl.col("believable")).sort("reported_value", descending=True).head(5).select(
    "company_name",
    "positions",
    (pl.col("reported_value") / 1e9).round(1).alias("reported_bn"),
    pl.col("median_implied_price").round(2),
)

# %% [markdown]
# The two failures are different sizes and different problems. A handful of filings imply prices
# in the tens of thousands of dollars a share and carry trillions of reported value between them,
# which is enough to put a small trust company above Vanguard in an unscreened ranking by size.
# Several hundred more imply prices below a dollar; they are numerous and carry almost nothing, so
# they distort a count of managers rather than a total. Reporting both, separately, is what lets a
# reader decide whether the screen was the right one, and looking at a few of each is what
# suggests where the error is - which the file itself never states.

# %%
top_managers = kept.sort("reported_value", descending=True).head(TOP_N)
print(
    f"Reported 13F equity value across the top {TOP_N} managers: ${top_managers['reported_value'].sum() / 1e12:.1f}T"
)
top_managers.head(15).select(
    "company_name",
    (pl.col("reported_value") / 1e9).round(1).alias("reported_bn"),
    "positions",
)

# %% [markdown]
# A 13F reports long positions in US-listed equities and the options on them. It is not assets
# under management: a manager's bonds, cash, private holdings and non-US listings are all absent,
# and a short book is absent too. "Reported 13F value" is the phrase to use for these figures,
# and the distinction matters most for exactly the managers at the top of this table, whose
# non-equity businesses are large.

# %% [markdown]
# ## 3. What the top managers hold
#
# Restricting the position rows to the filings that survived screening gives the panel every
# aggregate below is computed on. Grouping is on CUSIP, with one spelling of the name carried
# through for legibility.

# %%
top_holdings = holdings.join(top_managers.select("accession_no"), on="accession_no", how="semi")
print(f"Positions held by the top {TOP_N} managers: {len(top_holdings):,}")

by_security = (
    top_holdings.group_by("cusip")
    .agg(
        pl.col("issuer").mode().first().alias("issuer"),
        pl.col("cik").n_unique().alias("managers_holding"),
        pl.col("value_thousands").sum().alias("aggregate_value"),
        pl.col("shares").sum().alias("aggregate_shares"),
    )
    .sort("managers_holding", descending=True)
)
by_security.head(15).select(
    "issuer",
    "managers_holding",
    (pl.col("aggregate_value") / 1e9).round(1).alias("aggregate_bn"),
)

# %% [markdown]
# ### Breadth and size are different questions
#
# The number of managers holding a security says how widely it is owned; the aggregate value says
# how much is in it, and the two are only loosely related. Among the most widely held names below,
# securities sitting within a few managers of each other differ by several times in the value
# behind them. Crowding, in the sense that matters when everyone reaches for the exit at once, is
# the breadth measure; size is what decides how much has to go through the exit.

# %%
breadth_leaders = by_security.head(15).with_columns(
    pl.col("managers_holding").cast(pl.Int64)  # a uint reads as categorical and loses the scale
)
fig = px.bar(
    breadth_leaders.to_pandas(),
    x="issuer",
    y="aggregate_value",
    color="managers_holding",
    title="Names held by as many managers differ several-fold in size",
    labels={
        "aggregate_value": "Aggregate reported value (USD)",
        "issuer": "",
        "managers_holding": "Managers holding",
    },
    color_continuous_scale=[COLORS["silver_muted"], COLORS["blue"]],
)
fig.update_layout(xaxis_tickangle=45, margin=dict(b=150, r=120), height=520)
show_plotly_with_alt(
    fig,
    "Bar chart of the fifteen most widely held securities, ordered by the number of managers "
    "holding them and with bar height showing aggregate reported value. The bar heights vary "
    "several-fold across names that are held by similar numbers of managers.",
)

# %% [markdown]
# **What breadth does not measure.** A manager holding a hundred dollars of a stock counts the
# same as one holding a billion, and an index fund holds nearly everything, so the top of a
# breadth ranking is partly a list of index constituents. Weighting by position value, or
# restricting to managers whose portfolios are concentrated, are the two usual corrections, and
# both are choices a reader should make deliberately rather than inherit.

# %% [markdown]
# ## 4. Ownership as a graph
#
# Two edge sets come out of a holdings panel, and Chapter 23's graph models consume both. The
# **manager to security** edges are bipartite, one per position, weighted by value. The
# **security to security** edges connect two stocks whenever enough managers hold both, which is
# what lets a signal on one name propagate to its co-owned peers.

# %%
manager_edges = top_holdings.group_by("cik", "company_name", "cusip").agg(
    pl.col("issuer").mode().first().alias("issuer"),
    pl.col("value_thousands").sum().alias("position_value"),
)
print(f"Manager-to-security edges: {len(manager_edges):,}")
manager_edges.head(5)


# %%
def co_ownership_edges(positions: pl.DataFrame, min_shared: int, universe_cap: int) -> pl.DataFrame:
    """Securities held by at least `min_shared` managers in common, pairwise.

    The pass is quadratic in the number of securities, so it runs over the `universe_cap`
    most widely held rather than over the whole cross-section.
    """
    holders = (
        positions.group_by("cusip")
        .agg(
            pl.col("issuer").mode().first().alias("issuer"),
            pl.col("cik").unique().alias("managers"),
        )
        .with_columns(n_managers=pl.col("managers").list.len())
        .sort("n_managers", descending=True)
        .head(universe_cap)
    )
    cusips = holders["cusip"].to_list()
    issuers = holders["issuer"].to_list()
    manager_sets = [set(m) for m in holders["managers"].to_list()]

    edges = []
    for i in range(len(cusips)):
        for j in range(i + 1, len(cusips)):
            shared = len(manager_sets[i] & manager_sets[j])
            if shared >= min_shared:
                edges.append(
                    {
                        "cusip_a": cusips[i],
                        "issuer_a": issuers[i],
                        "cusip_b": cusips[j],
                        "issuer_b": issuers[j],
                        "shared_managers": shared,
                    }
                )
    return pl.DataFrame(
        edges,
        schema={
            "cusip_a": pl.Utf8,
            "issuer_a": pl.Utf8,
            "cusip_b": pl.Utf8,
            "issuer_b": pl.Utf8,
            "shared_managers": pl.Int64,
        },
    )


# %%
co_owned = co_ownership_edges(top_holdings, MIN_SHARED_MANAGERS, CO_OWNERSHIP_UNIVERSE)
# The universe is the cap or the number of securities available, whichever is smaller; using the
# cap where it exceeds what is there would divide by pairs that were never considered.
universe = min(CO_OWNERSHIP_UNIVERSE, top_holdings["cusip"].n_unique())
possible_pairs = universe * (universe - 1) // 2

print(f"Securities in the pairwise pass: {universe:,}")
print(f"Pairs considered: {possible_pairs:,}")
print(f"Edges at {MIN_SHARED_MANAGERS} or more shared managers: {len(co_owned):,}")
print(f"Density of the resulting graph: {len(co_owned) / possible_pairs:.1%}")

# %% [markdown]
# ### The density is the first thing to look at
#
# A graph in which every pair is connected carries no information, whatever the edges are called.
# The threshold above was chosen as a round number rather than from the data, and the distribution
# of shared-manager counts says what a round number was always going to miss.

# %%
shared_counts = co_ownership_edges(top_holdings, 0, universe)["shared_managers"]
print(
    f"Managers two of these securities share, at the tenth percentile: {shared_counts.quantile(0.1):.0f}"
)
print(f"Median: {shared_counts.median():.0f}")
print(f"Smallest overlap between any two: {shared_counts.min():,}")

density = pl.DataFrame(
    {
        "threshold": THRESHOLD_GRID,
        "density": [(shared_counts >= t).mean() for t in THRESHOLD_GRID],
    }
)
density

# %%
fig = px.line(
    density.to_pandas(),
    x="threshold",
    y="density",
    markers=True,
    title="Every threshold below the typical overlap connects the whole graph",
    labels={
        "threshold": "Shared managers required for an edge",
        "density": "Share of pairs connected",
    },
    color_discrete_sequence=[COLORS["blue"]],
)
fig.update_layout(height=380, yaxis_tickformat=".0%")
show_plotly_with_alt(
    fig,
    "Line chart of the share of security pairs that are connected against the number of shared "
    "managers required. The line sits at one hundred percent across most of the range and falls "
    "steeply only above two hundred shared managers.",
)

# %% [markdown]
# ### Normalizing does not rescue it
#
# The obvious repair is to divide the shared count by how widely the less-held of the two is
# held, which is the overlap coefficient: one means every manager holding the rarer security also
# holds the other. It corrects for breadth, and here it saturates too.

# %%
breadth = dict(zip(by_security["cusip"], by_security["managers_holding"], strict=True))
overlap = co_ownership_edges(top_holdings, 0, universe).with_columns(
    overlap_coefficient=pl.struct("cusip_a", "cusip_b", "shared_managers").map_elements(
        lambda row: row["shared_managers"] / min(breadth[row["cusip_a"]], breadth[row["cusip_b"]]),
        return_dtype=pl.Float64,
    )
)
print(f"Median overlap coefficient: {overlap['overlap_coefficient'].median():.2f}")
print(f"Share of pairs above 0.9: {(overlap['overlap_coefficient'] > 0.9).mean():.1%}")
overlap.sort("overlap_coefficient", descending=True).head(5).select(
    "issuer_a", "issuer_b", "shared_managers", pl.col("overlap_coefficient").round(3)
)

# %% [markdown]
# The pairs at the top of that list are not economically related to each other. They are ordinary
# index constituents, and what the edge says is that the same large managers hold both, because
# those managers hold the index.
#
# The universe is the reason, and it was chosen two cells earlier: the five hundred most widely
# held securities, held by the five hundred largest managers, are the securities and the managers
# for which co-ownership is nearly universal by construction. A discriminating version of this
# graph needs a universe that was not selected on breadth, or managers whose portfolios are
# concentrated rather than index-tracking. Building the edges is easy; making them mean something
# is a question about which slice of the file they are built from.
#
# The edges here are illustrative. `22_rag_financial_research/07_institutional_holdings_graph.py`
# reads the canonical artifact through `load_13f_edges`, which the downloader builds over the
# curated multi-quarter panel rather than over one quarter of the bulk file.

# %% [markdown]
# ## 5. Bulk archive against live filings
#
# The two ingestion paths answer different questions, and the difference is timing.
#
# | Mode | Source | Scope | Answers |
# | --- | --- | --- | --- |
# | `--mode bulk` | The quarterly zip | Every filer, one quarter | What did the whole institutional cross-section hold? |
# | `--mode per-cik` | The submissions API and each filing's XML | A named list of managers, many quarters | How has this manager's book changed? |
#
# A quarter's positions are reported as of the quarter end and are due forty-five days later, so
# the newest position in a filing is at least six weeks old when it becomes public. The bulk
# archive is assembled after the filing window closes, which adds several weeks more. Anything
# that depends on reacting to a disclosure reads the individual filings from EDGAR as they land,
# which is public information the moment it is submitted; the bulk file is for the cross-section,
# not for the reaction.

# %% [markdown]
# ## Key Takeaways
#
# 1. Group on the identifier, never on the name. A filer types the issuer name and thousands of
#    securities in this archive carry more than one spelling, with the most widely held names
#    carrying the most. A ranking built on names is wrong worst exactly where it is read.
# 2. A bulk regulatory file is not clean because it is official. A few filings are internally
#    inconsistent and they land at the top of any ranking by size. The filing supplies its own
#    check without needing to know which of its columns is wrong: value divided by shares is a
#    price, and a price has a knowable range.
# 3. Say what a screen removed. Reporting the number of filings and the share of the total value
#    dropped is what separates a screen from a quiet deletion.
# 4. An accession number is not a timestamp. Its leading block identifies the filing agent, so
#    ordering by it ranks agents. Use the filing date.
# 5. Breadth and size are different measures of institutional interest and rank differently.
#    Breadth is the crowding proxy, and it weights a hundred-dollar position the same as a
#    billion-dollar one, which is the correction to make before acting on it.
# 6. Report a graph's density before believing its edges. Among widely held securities held by
#    large managers, every pair shares hundreds of holders, so any round-number threshold connects
#    everything and normalizing by breadth saturates as well. The edge set then describes index
#    membership. Fixing it is a question about which slice of the file the graph is built from,
#    not about the threshold.

```

با ذکر منبع و مطابق مجوز اثر، به‌طور کامل نمایش داده می‌شود. مجوز: MIT

این خلاصه را عامل پژوهشی Stratmill بر پایه متن اصلی نوشته است؛ نسخه‌ای از اثر منبع نیست.