<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[13F Insights]]></title><description><![CDATA[13F Insights]]></description><link>https://13f-insights.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Tue, 27 Oct 2026 20:14:58 GMT</lastBuildDate><atom:link href="https://13f-insights.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Joining 13F Filings Across Quarters in Pandas (New, Increased, Decreased, Exited)]]></title><description><![CDATA[A single 13F snapshot answers “what did this manager report this quarter?” Comparing two adjacent quarters answers a more useful engineering question…]]></description><link>https://13f-insights.hashnode.dev/joining-13f-filings-across-quarters-in-pandas-new-increased-decreased-exited</link><guid isPermaLink="true">https://13f-insights.hashnode.dev/joining-13f-filings-across-quarters-in-pandas-new-increased-decreased-exited</guid><dc:creator><![CDATA[13F Chat]]></dc:creator><pubDate>Tue, 27 Oct 2026 06:23:22 GMT</pubDate><content:encoded><![CDATA[<p>A single 13F snapshot answers “what did this manager report this quarter?” Comparing two adjacent quarters answers a more useful engineering question: which CUSIPs are new, which grew, which shrank, and which disappeared. This post shows a compact pandas join that classifies those four states without a heavyweight pipeline.</p>
<p>Hashnode note up front: this is a data-wrangling article. It is not portfolio advice.</p>
<h2>1. Normalize two quarter tables first</h2>
<p>Assume you already parsed the information table XML into rows with at least:</p>
<ul>
<li><code>cusip</code></li>
<li><code>nameofissuer</code></li>
<li><code>value</code> (USD thousands, as filed)</li>
<li><code>sshprnamt</code> (share count or principal)</li>
<li><code>sshprnamt_type</code> (<code>SH</code> / <code>PRN</code>)</li>
<li><code>put_call</code> (nullable)</li>
</ul>
<pre><code class="language-python">import pandas as pd

def norm(df: pd.DataFrame, tag: str) -&gt; pd.DataFrame:
    out = df.copy()
    out["cusip"] = out["cusip"].astype(str).str.strip().str.upper()
    out["value"] = pd.to_numeric(out["value"], errors="coerce")
    out["sshprnamt"] = pd.to_numeric(out["sshprnamt"], errors="coerce")
    out["put_call"] = out["put_call"].fillna("").astype(str).str.upper()
    out["leg"] = tag
    return out

q1 = norm(raw_q1, "q1")  # prior quarter
q2 = norm(raw_q2, "q2")  # current quarter
</code></pre>
<p>Put/call legs must stay in the key. Otherwise a long equity row collapses into a put row with the same CUSIP.</p>
<h2>2. Build a stable join key</h2>
<pre><code class="language-python">KEY = ["cusip", "put_call", "sshprnamt_type"]

def keyed(df: pd.DataFrame) -&gt; pd.DataFrame:
    # One row per key; if duplicates exist, keep the largest value row and log
    d = df.sort_values("value", ascending=False)
    dup = d.duplicated(KEY, keep="first").sum()
    if dup:
        print(f"warning: dropping {dup} duplicate keys")
    return d.drop_duplicates(KEY, keep="first").set_index(KEY)
</code></pre>
<p>Duplicates happen when issuers use multiple voting/investment discretion rows. Decide a rule (max value, sum, or keep both with an extra key column). Silent sums hide bugs.</p>
<h2>3. Outer-join and classify</h2>
<pre><code class="language-python">a = keyed(q1).add_suffix("_q1")
b = keyed(q2).add_suffix("_q2")
m = a.join(b, how="outer")

def classify(r) -&gt; str:
    v1, v2 = r.get("value_q1"), r.get("value_q2")
    if pd.isna(v1) and not pd.isna(v2):
        return "new"
    if not pd.isna(v1) and pd.isna(v2):
        return "exited"
    if v2 &gt; v1 * 1.05:
        return "increased"
    if v2 &lt; v1 * 0.95:
        return "decreased"
    return "unchanged"

m["state"] = m.apply(classify, axis=1)
m["delta_value"] = m["value_q2"].fillna(0) - m["value_q1"].fillna(0)
</code></pre>
<p>The 5% band around “unchanged” absorbs trivial mark-to-market noise when share counts barely move. Tune it; keep it explicit.</p>
<h2>4. Share-count check beside value delta</h2>
<p>Value can rise while shares fall (price move). For equity legs (<code>SH</code>), compare share deltas too:</p>
<pre><code class="language-python">eq = m[m.index.get_level_values("sshprnamt_type") == "SH"].copy()
eq["delta_shares"] = eq["sshprnamt_q2"].fillna(0) - eq["sshprnamt_q1"].fillna(0)
print(eq.groupby("state")["delta_value"].agg(["count", "sum"]).sort_index())
</code></pre>
<p>A cluster of <code>increased</code> with negative <code>delta_shares</code> is usually price, not a fresh buy. Label those before you write a narrative.</p>
<h2>5. Sanity asserts worth keeping</h2>
<pre><code class="language-python">assert m.index.is_unique
assert m["state"].isna().sum() == 0
# Every q2 row should appear
assert keyed(q2).index.difference(m.index).empty
print(m["state"].value_counts())
</code></pre>
<p>If <code>new</code> + <code>exited</code> explode between two adjacent quarters, check whether you mixed amendment vs original filings, or whether one file still has put/call blank while the other uses <code>PUT</code>/<code>CALL</code>.</p>
<h2>6. Where a tracker saves time</h2>
<p>Once the classifier works on one manager, the boring part is fetching the right accession for each quarter and keeping issuer names aligned. A lightweight filing tracker such as <a href="https://13f.chat">https://13f.chat</a> is enough to jump to the latest accepted 13F and confirm you did not pair an amendment with the wrong original. The join logic above still belongs in your notebook; the tracker just reduces “wrong file” mistakes.</p>
<h2>Takeaway</h2>
<p>Cross-quarter 13F work is an outer join plus an explicit state machine. Keep put/call and share-type in the key, separate price effects from share effects, and assert index uniqueness before you chart anything. The code is short on purpose — most errors are in filing selection, not in pandas.</p>
<p><em>Education only — not investment advice.</em></p>
]]></content:encoded></item><item><title><![CDATA[Computing Rolling Inventory Z-Scores for Crude Oil in Python]]></title><description><![CDATA[US EIA weekly petroleum status data is one of the few free, high-frequency series that still move oil prices. The headline number people quote is the crude…]]></description><link>https://13f-insights.hashnode.dev/computing-rolling-inventory-z-scores-for-crude-oil-in-python</link><guid isPermaLink="true">https://13f-insights.hashnode.dev/computing-rolling-inventory-z-scores-for-crude-oil-in-python</guid><dc:creator><![CDATA[13F Chat]]></dc:creator><pubDate>Sat, 24 Oct 2026 06:19:54 GMT</pubDate><content:encoded><![CDATA[<p>US EIA weekly petroleum status data is one of the few free, high-frequency series that still move oil prices. The headline number people quote is the crude inventory change in thousand barrels. That number alone is noisy. A more useful view is where <em>this week's level</em> sits relative to the last year of the same series.</p>
<p>This post shows a compact pandas pipeline: load weekly inventories, compute a trailing z-score, and flag weeks that are unusually high or low. The math is short; the gotchas are calendar alignment and units.</p>
<h2>1. Shape of the series</h2>
<p>Assume you already have a DataFrame with one row per week:</p>
<pre><code class="language-python">import pandas as pd

# df columns: date (Timestamp, week ending), invent_kb (thousand barrels)
df = df.sort_values("date").reset_index(drop=True)
df["invent_kb"] = pd.to_numeric(df["invent_kb"], errors="coerce")
print(df.tail())
</code></pre>
<p>EIA publishes several stocks series (commercial crude, excluding SPR, Cushing, etc.). Pick one and stick to it. Mixing SPR and commercial stocks is a classic reason two charts disagree.</p>
<h2>2. Trailing mean and z-score</h2>
<p>A 52-week window matches the seasonal habit of comparing "this week vs the past year":</p>
<pre><code class="language-python">WINDOW = 52

df["mean_52"] = df["invent_kb"].rolling(WINDOW, min_periods=WINDOW).mean()
df["std_52"] = df["invent_kb"].rolling(WINDOW, min_periods=WINDOW).std(ddof=0)
df["z_52"] = (df["invent_kb"] - df["mean_52"]) / df["std_52"]
</code></pre>
<p>Using <code>ddof=0</code> keeps the z-score aligned with how many dashboard tools define population sigma. If you prefer sample sigma, switch to <code>ddof=1</code>; just be consistent when you compare charts.</p>
<h2>3. Flag extremes without over-fitting</h2>
<pre><code class="language-python">df["flag"] = pd.cut(
    df["z_52"],
    bins=[-float("inf"), -2, -1, 1, 2, float("inf")],
    labels=["very_low", "low", "normal", "high", "very_high"],
)

print(df.dropna(subset=["z_52"]).tail(8)[
    ["date", "invent_kb", "z_52", "flag"]
])
</code></pre>
<p>A z-score below -2 does not mean "buy oil." It means inventories are unusually low versus the trailing year. Combine it with the weekly <em>change</em> and with products (gasoline, distillate) before drawing a conclusion.</p>
<h2>4. A tiny sanity check</h2>
<pre><code class="language-python">assert df["date"].is_monotonic_increasing
assert df["invent_kb"].isna().mean() &lt; 0.02
# After warm-up, z should be roughly centered
warm = df.dropna(subset=["z_52"])
print(warm["z_52"].mean(), warm["z_52"].std())
</code></pre>
<p>If the mean of z drifts far from zero, your window is too short or the series has a structural break (methodology change, SPR release programme, etc.).</p>
<h2>5. Where to look at the finished chart</h2>
<p>Once the pipeline runs, it helps to eyeball the same series on a maintained chart before you trust a trading narrative. I cross-check the weekly crude inventory path on <a href="https://commoditieschart.net">CommoditiesChart</a> (the crude inventories view) against my <code>z_52</code> flags. If the chart shows a steep drawdown and my z-score is still near zero, I usually have a units or series-id bug rather than a market insight.</p>
<h2>Takeaway</h2>
<p>Rolling z-scores turn a noisy weekly inventory print into a relative statement: <em>how unusual is this level?</em> Keep the window explicit, keep SPR and commercial stocks separate, and verify the plot against a known chart before acting on a flag.</p>
<p><em>Education only — not trading advice.</em></p>
]]></content:encoded></item><item><title><![CDATA[Computing the Gold/Silver Ratio in Python: Alignment, Rolling Z-Scores and a Backtest Sanity Check]]></title><description><![CDATA[The gold/silver ratio is one of the oldest numbers in commodity markets: the price of one ounce of gold divided by the price of one ounce of silver. It…]]></description><link>https://13f-insights.hashnode.dev/computing-the-gold-silver-ratio-in-python-alignment-rolling-z-scores-and-a-backtest-sanity-check</link><guid isPermaLink="true">https://13f-insights.hashnode.dev/computing-the-gold-silver-ratio-in-python-alignment-rolling-z-scores-and-a-backtest-sanity-check</guid><category><![CDATA[Python]]></category><category><![CDATA[pandas]]></category><category><![CDATA[data analysis]]></category><dc:creator><![CDATA[13F Chat]]></dc:creator><pubDate>Wed, 21 Oct 2026 06:18:24 GMT</pubDate><content:encoded><![CDATA[<p>The gold/silver ratio is one of the oldest numbers in commodity markets: the price of one ounce of gold divided by the price of one ounce of silver. It tells you how many ounces of silver one ounce of gold buys. Traders watch it because it has historically swung inside a wide band (roughly 30 to above 120 over the last fifty years, with the spike above 120 in March 2020) and people like to bet on it drifting back.</p>
<p>Computing a ratio sounds like a one-liner. In practice most of the work is in getting two price series to line up properly, choosing a lookback window, and not fooling yourself with a backtest. This post walks through all three with pandas.</p>
<h2>1. Load two price series</h2>
<p>Any daily source works. For a reproducible example I'll use the front-month futures from <code>yfinance</code>, but the code below only assumes you end up with two <code>pd.Series</code> indexed by date.</p>
<pre><code class="language-python">import pandas as pd
import yfinance as yf

raw = yf.download(["GC=F", "SI=F"], start="2005-01-01", auto_adjust=False, progress=False)
close = raw["Close"].rename(columns={"GC=F": "gold", "SI=F": "silver"})
print(close.tail())
</code></pre>
<p>If you have spot prices from a CSV instead (LBMA fixes, a broker export, etc.), read them with <code>parse_dates</code> and <code>index_col</code> so the index is a real <code>DatetimeIndex</code>. That matters for every step below.</p>
<h2>2. Align before you divide</h2>
<p>The first trap: the two series don't always have the same trading days. Holidays differ between exchanges and data vendors, and some sources skip days with no fix. If you divide two misaligned series, pandas aligns on the index and silently produces <code>NaN</code> where either side is missing. That's actually the safe behaviour. The dangerous version is forward-filling everything first.</p>
<pre><code class="language-python">both = close.dropna(how="any")          # keep only days where both prices exist
ratio = (both["gold"] / both["silver"]).rename("gs_ratio")

missing = close.isna().any(axis=1).sum()
print(f"dropped {missing} days with a missing leg")
</code></pre>
<p>My rule of thumb:</p>
<ul>
<li><p><strong>Drop</strong> days where either leg is missing for daily analysis.</p>
</li>
<li><p><strong>Forward-fill at most one day</strong> (<code>ffill(limit=1)</code>) if you need a continuous series for charting.</p>
</li>
<li><p>Never forward-fill across long gaps. A stale silver price from a week ago divided by today's gold price is a made-up number.</p>
</li>
</ul>
<p>Before writing analysis code, it helps to look at the long-run price history of each metal so you know what "normal" looks like; the gold and silver USD price charts <a href="https://commoditieschart.net/metals/gold/global-gold-usd-prices">here</a> are a quick visual reference. It makes it much easier to spot a bad tick or a unit error (for example a silver series accidentally in cents) when your ratio suddenly prints 7,000.</p>
<h2>3. Sanity-check units and outliers</h2>
<p>A quick set of assertions saves a lot of debugging later:</p>
<pre><code class="language-python">assert ratio.between(10, 200).all(), ratio[~ratio.between(10, 200)]

daily_change = ratio.pct_change().abs()
spikes = daily_change[daily_change &gt; 0.08]
print(spikes)
</code></pre>
<p>A ratio move of more than 8% in a day does happen (March 2020 had several), but each one is worth eyeballing. If the spike reverses completely the next day, it's usually a bad print in one leg rather than a real market move.</p>
<h2>4. Rolling z-score</h2>
<p>"The ratio is high" only means something relative to a window. A rolling z-score expresses today's value in standard deviations from its recent mean:</p>
<pre><code class="language-python">def rolling_z(s: pd.Series, window: int = 252) -&gt; pd.Series:
    mean = s.rolling(window, min_periods=window).mean()
    std = s.rolling(window, min_periods=window).std()
    return ((s - mean) / std).rename(f"z_{window}")

z1 = rolling_z(ratio, 252)     # about one trading year
z3 = rolling_z(ratio, 756)     # about three years
</code></pre>
<p>Two details:</p>
<ol>
<li><p><code>min_periods=window</code> keeps the first year as <code>NaN</code> instead of computing a z-score from ten data points. Without it, the early values look dramatic and are meaningless.</p>
</li>
<li><p>Consider computing on <code>np.log(ratio)</code> instead of the raw ratio. Ratios are multiplicative; moving from 60 to 80 and from 80 to 106.7 are the same percentage move, and log space treats them that way.</p>
</li>
</ol>
<pre><code class="language-python">import numpy as np
zlog = rolling_z(np.log(ratio), 252)
</code></pre>
<h2>5. A backtest sanity check (not a strategy)</h2>
<p>The natural next question is whether "buy silver vs gold when z &gt; 2" works. I'm not going to claim it does; the point of this section is to show how to test the idea without lying to yourself.</p>
<pre><code class="language-python">signal = pd.Series(0, index=ratio.index)
signal[zlog &gt; 2] = 1      # ratio stretched high: long silver / short gold
signal[zlog &lt; -2] = -1    # ratio stretched low: long gold / short silver

# Trade on the NEXT day's return, never the same day
rel_ret = np.log(both["silver"]).diff() - np.log(both["gold"]).diff()
pnl = signal.shift(1) * rel_ret

print("days in market:", (signal != 0).mean().round(3))
print("annualized mean:", (pnl.mean() * 252).round(4))
print("annualized vol:", (pnl.std() * np.sqrt(252)).round(4))
</code></pre>
<p>The single most important line is <code>signal.shift(1)</code>. Without the shift, you're using today's closing ratio to trade today's return, which is look-ahead bias, and almost any mean-reversion rule looks brilliant that way.</p>
<p>Other things to check before trusting any result:</p>
<ul>
<li><p><strong>Costs.</strong> Futures roll costs and spreads are real. Subtract a few basis points per trade at minimum.</p>
</li>
<li><p><strong>Regime dependence.</strong> Split the sample (e.g. before and after 2013) and see whether the result survives in both halves.</p>
</li>
<li><p><strong>Window sensitivity.</strong> If the rule only works with a 252-day window and falls apart at 200 or 300, it's probably fitted to noise.</p>
</li>
</ul>
<h2>6. Plot it</h2>
<pre><code class="language-python">import matplotlib.pyplot as plt

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 6), sharex=True)
ratio.plot(ax=ax1, lw=1)
ax1.set_ylabel("gold / silver")
zlog.plot(ax=ax2, lw=1)
for lvl in (-2, 2):
    ax2.axhline(lvl, ls="--", lw=0.8)
ax2.set_ylabel("z (log, 252d)")
plt.tight_layout()
plt.show()
</code></pre>
<h2>Wrap-up</h2>
<p>The ratio itself is a division. Getting it right means aligning dates before dividing, refusing to forward-fill long gaps, checking units with a couple of assertions, using a rolling z-score in log space, and shifting signals by a day before you look at any backtest number. The same pattern works for any pair of related series: Brent vs WTI, copper vs gold, or two share classes of the same company.</p>
]]></content:encoded></item><item><title><![CDATA[Parsing SEC 13F Information Table XML in Python (Namespaces, Units and Put/Call Rows)]]></title><description><![CDATA[Institutional investment managers with more than $100 million in qualifying US equities file a Form 13F-HR with the SEC every quarter. The holdings…]]></description><link>https://13f-insights.hashnode.dev/parsing-sec-13f-information-table-xml-in-python-namespaces-units-and-put-call-rows</link><guid isPermaLink="true">https://13f-insights.hashnode.dev/parsing-sec-13f-information-table-xml-in-python-namespaces-units-and-put-call-rows</guid><category><![CDATA[Python]]></category><category><![CDATA[xml]]></category><dc:creator><![CDATA[13F Chat]]></dc:creator><pubDate>Mon, 19 Oct 2026 06:17:38 GMT</pubDate><content:encoded><![CDATA[<p>Institutional investment managers with more than $100 million in qualifying US equities file a Form 13F-HR with the SEC every quarter. The holdings themselves are not in the main filing document. They sit in a separate XML file called the <em>information table</em>. If you've ever tried to parse one with a quick regex or <code>pandas.read_xml</code>, you've probably hit at least one of the problems below.</p>
<p>This post walks through a small parser built on the standard library plus <code>requests</code> and <code>pandas</code>, and the edge cases I had to handle to get clean numbers.</p>
<h2>Step 1: Find the filings</h2>
<p>EDGAR's submissions API returns a company's recent filings as JSON. The SEC asks every client to send a descriptive <code>User-Agent</code> with a contact email, and it rate-limits clients that don't (or that send more than 10 requests a second).</p>
<pre><code class="language-python">import requests

HEADERS = {"User-Agent": "Your Name your-email@example.com"}

def recent_13f(cik: int):
    url = f"https://data.sec.gov/submissions/CIK{cik:010d}.json"
    data = requests.get(url, headers=HEADERS, timeout=30).json()
    recent = data["filings"]["recent"]
    for form, acc, date in zip(recent["form"],
                               recent["accessionNumber"],
                               recent["filingDate"]):
        if form in ("13F-HR", "13F-HR/A"):
            yield form, acc, date

for row in list(recent_13f(1067983))[:4]:   # Berkshire Hathaway
    print(row)
</code></pre>
<p>Note that amendments (<code>13F-HR/A</code>) show up in the same list. Some amendments restate the whole table, others only add new holdings. The <code>amendmentType</code> element in the primary document tells you which, so don't blindly add an amendment's rows to the original.</p>
<h2>Step 2: Locate the information table file</h2>
<p>The file name isn't fixed. You'll see <code>infotable.xml</code>, <code>form13fInfoTable.xml</code>, or a numeric name. The reliable way is to read the filing index and pick the XML file that isn't <code>primary_doc.xml</code>:</p>
<pre><code class="language-python">def info_table_url(cik: int, accession: str) -&gt; str:
    acc = accession.replace("-", "")
    base = f"https://www.sec.gov/Archives/edgar/data/{cik}/{acc}/"
    idx = requests.get(base + "index.json", headers=HEADERS, timeout=30).json()
    for item in idx["directory"]["item"]:
        name = item["name"]
        if name.lower().endswith(".xml") and name != "primary_doc.xml":
            return base + name
    raise LookupError("no information table found")
</code></pre>
<h2>Step 3: Parse with namespaces</h2>
<p>Every element in the information table lives in the namespace <code>http://www.sec.gov/edgar/document/thirteenf/informationtable</code>. Forget the namespace and <code>find()</code> silently returns <code>None</code> for everything. Some filers also use a prefix (<code>ns1:infoTable</code>) and others use a default namespace. Matching on the namespace URI handles both.</p>
<pre><code class="language-python">import xml.etree.ElementTree as ET
import pandas as pd

NS = {"it": "http://www.sec.gov/edgar/document/thirteenf/informationtable"}

def text(el, path):
    node = el.find(path, NS)
    return node.text.strip() if node is not None and node.text else None

def parse_info_table(xml_bytes: bytes) -&gt; pd.DataFrame:
    root = ET.fromstring(xml_bytes)
    rows = []
    for t in root.findall("it:infoTable", NS):
        rows.append({
            "issuer": text(t, "it:nameOfIssuer"),
            "class": text(t, "it:titleOfClass"),
            "cusip": text(t, "it:cusip"),
            "value": int(text(t, "it:value") or 0),
            "shares": int(text(t, "it:shrsOrPrnAmt/it:sshPrnamt") or 0),
            "share_type": text(t, "it:shrsOrPrnAmt/it:sshPrnamtType"),
            "put_call": text(t, "it:putCall"),
            "discretion": text(t, "it:investmentDiscretion"),
            "vote_sole": int(text(t, "it:votingAuthority/it:Sole") or 0),
            "vote_shared": int(text(t, "it:votingAuthority/it:Shared") or 0),
            "vote_none": int(text(t, "it:votingAuthority/it:None") or 0),
        })
    return pd.DataFrame(rows)
</code></pre>
<p><code>ElementTree</code> is enough here. These files are rarely more than a few megabytes, so there's no need for <code>lxml</code> or a streaming parser.</p>
<h2>Step 4: The edge cases</h2>
<p><strong>Value units changed in 2023.</strong> Before the SEC's amendments took effect at the start of 2023, <code>value</code> was reported in thousands of dollars. Filings after that report whole dollars. If you compare a 2022 table with a 2024 table without adjusting, positions look like they grew 1,000x overnight. I normalize on the period of report:</p>
<pre><code class="language-python">def normalize_value(df: pd.DataFrame, period_of_report: str) -&gt; pd.DataFrame:
    if period_of_report &lt; "2022-12-31":
        df["value"] = df["value"] * 1000
    return df
</code></pre>
<p>Some filers got the change wrong for a quarter or two, so it's also worth a sanity check: divide <code>value</code> by <code>shares</code> and flag any row whose implied price is wildly off from the market price that quarter.</p>
<p><strong>Duplicate CUSIPs.</strong> One position can be split across several rows, for example by <code>otherManager</code> or investment discretion. Group before you analyze:</p>
<pre><code class="language-python">holdings = (df[df["put_call"].isna()]
            .groupby(["cusip", "issuer", "class"], as_index=False)
            [["value", "shares"]].sum()
            .sort_values("value", ascending=False))
</code></pre>
<p><strong>Options rows.</strong> Rows with <code>putCall</code> set to <code>Put</code> or <code>Call</code> report the <em>underlying</em> shares and value, not the option premium. Mixing them into long equity positions overstates exposure, which is why the snippet above filters them out. Keep them in a separate frame if you need them.</p>
<p><strong>Principal amounts.</strong> <code>sshPrnamtType</code> is <code>SH</code> for shares and <code>PRN</code> for principal amount (convertible notes and similar). A <code>PRN</code> row's "shares" is a dollar face amount, so drop or separate these before computing share changes.</p>
<h2>Step 5: Quarter-over-quarter changes</h2>
<p>With two clean frames, position changes are a merge:</p>
<pre><code class="language-python">def diff_quarters(prev: pd.DataFrame, curr: pd.DataFrame) -&gt; pd.DataFrame:
    m = prev.merge(curr, on="cusip", how="outer",
                   suffixes=("_prev", "_curr")).fillna(0)
    m["share_change"] = m["shares_curr"] - m["shares_prev"]
    m["status"] = "changed"
    m.loc[m["shares_prev"] == 0, "status"] = "new"
    m.loc[m["shares_curr"] == 0, "status"] = "exited"
    return m.sort_values("share_change")
</code></pre>
<p>Join on CUSIP, not on issuer name. Names vary between filings ("APPLE INC" vs "Apple Inc."), but the CUSIP for a given security class stays the same.</p>
<p>When I want to check my parser's output against a finished view of the same filings, I compare a few managers on <a href="https://13f.chat">https://13f.chat</a>, which lays out the reported holdings for each fund. If my totals disagree, it's almost always one of the edge cases above, usually units or options rows.</p>
<h2>A few more gotchas</h2>
<ul>
<li><p><strong>Confidential treatment.</strong> Managers can ask the SEC to keep some positions confidential for a while. Those holdings show up later in an amendment, so a table can be legitimately incomplete.</p>
</li>
<li><p><strong>45-day lag.</strong> Filings are due 45 days after quarter end. Treat the data as a snapshot of the past, not live positions.</p>
</li>
<li><p><strong>Long US positions only.</strong> Shorts, most non-US listings and cash aren't on the form. A 13F is a partial picture of a portfolio.</p>
</li>
<li><p><strong>Be polite to EDGAR.</strong> Cache everything you download and stay under 10 requests per second, or your IP gets blocked for a while.</p>
</li>
</ul>
<h2>Wrap-up</h2>
<p>The parser itself is short. Most of the work is in the details: the namespace, the 2023 change in value units, splitting out options and principal rows, and grouping duplicate CUSIPs. Handle those four and you can turn any 13F-HR information table into a tidy DataFrame you can trust.</p>
]]></content:encoded></item></channel></rss>