Skip to content
EDGE·THIRTEENSubscribe

Engineering notes · 2026-09-12

The bug that fed our screener table-of-contents text instead of real risk factors

For months, the qualitative research step behind every Edge Thirteen pick was reading the wrong four sentences out of every 10-K and 10-Q it touched — and nothing about the failure looked like a bug.

The setup

Before Edge Thirteen calls anything a value pick, a step in the pipeline pulls the latest 10-K and 10-Q straight from SEC EDGAR and extracts two sections: the risk-factor summary and the latest results discussion (MD&A). That text is what grounds the write-up — it's the difference between "the screen says this is cheap" and actually having read why.

The extraction was simple: strip HTML tags, collapse whitespace, then search for a heading like "Item 1A. Risk Factors"and grab the text after the first match. Straightforward, and it worked in every manual spot-check — because a spot-check means reading the output and confirming it's plausible-looking prose about risk, which it always was.

Every heading appears twice — and the first match is always the wrong one

Every EDGAR 10-K and 10-Q opens with a table of contents that lists every "Item X." heading by name, each one followed by a page number. That means the exact heading text you're searching for appears once in the TOC near the top of the document, and once again — often tens of thousands of characters later — as the real section start.

A first-match search finds the TOC line every time. And the TOC line isn't empty — it's a short, real string of text (the heading, maybe a subtitle, a page number) that reads as plausible output. Confirmed by hand against live filings for two real tickers in the Edge Thirteen pipeline, PIPR and SPNT: the "risk factors" the write-up stage was working from was consistently a table-of-contents fragment, not the actual disclosure.

That's the trap. A bug that returns nothing gets caught the first time someone looks at the output. A bug that returns something wrong but plausible survives indefinitely, because nothing about the failure demands a second look.

The fix: score every match, don't trust the first one

The fix doesn't special-case the table of contents — TOC formatting varies too much across filers to pattern-match reliably. Instead, it finds every occurrence of the heading text in the document and scores each one on how much it reads like real prose versus a list of headings and page numbers:

def section(t, starts, ends, n):
    # EDGAR filings always list every "Item X." heading (with a page number) in a table
    # of contents near the top. A naive first-match search on the heading text returns
    # that TOC line, not the actual section body pages later in the document. Score
    # every occurrence instead and keep the one that reads like real prose.
    low = t.lower()
    connectors = ("listed under", "described under", "described in", "set forth in",
                  "discussed under", "discussed in", "referenced in", "captioned",
                  "titled", "see item", "under item", "in item", "of item")
    keywords = ("$", "increase", "decrease", "compared to", "million", "net revenue",
                "net income", "primarily due to", "you should consider", "fluctuate",
                "adversely affect our", "quarter ended", "three months ended",
                "six months ended", "thousand")
    candidates = []
    for s in starts:
        for m in re.finditer(re.escape(s.lower()), low):
            i = m.start()
            if any(c in low[max(0, i - 40):i] for c in connectors):
                continue  # a cross-reference ("...listed under Item 1A."), not the heading itself
            window = low[i:i + 500]
            if window.count("item ") >= 2:
                continue  # still inside the table of contents
            digit_hits = len(re.findall(r"\b\d{1,4}\b", window))
            kw_hits = sum(window.count(k) for k in keywords)
            candidates.append((kw_hits * 3 - digit_hits, i))
    if not candidates:
        return "(section not found — open the filing manually)"
    candidates.sort(key=lambda c: (-c[0], c[1]))
    i = candidates[0][1]
    j = min([low.find(e.lower(), i + 300) for e in ends if low.find(e.lower(), i + 300) != -1] + [len(t)])
    return t[i:min(i + n, j)].strip()

Three signals do the work. Cross-reference phrases ("as described under Item 1A") rule out sentences that merely mention the heading in passing. A window still dense with the word "item" is still inside the table of contents, not past it. And real disclosure prose is dense with dollar signs, percentages, and phrases like "primarily due to" — while a list of headings and page numbers is dense with bare, short numbers and nothing else. Score every candidate on keyword hits minus digit density, and the real section wins every time.

The general lesson

If you're extracting a named section from any large structured document by searching for its own heading text, assume that heading also appears somewhere else in the document — a table of contents, an index, a cross-reference — and that the earlier, wrong occurrence will look like valid output, not an error. Score candidates by content, don't trust position.

Every value pick Edge Thirteen has published since this fix is grounded in the actual risk-factor and MD&A text, cross-checked against the numeric screen before anything gets called a value pick — the full method is on how it works, and every call is tracked, wins and losses, on the track record.

Share:X / TwitterLinkedIn

See the pipeline this feeds

Every week, Edge Thirteen screens roughly 5,000 U.S. stocks, reads the 10-K on every name that clears the numbers, and sends the genuine value picks — for $13/month. Cancel anytime.

Secure checkout via Stripe · cancel anytime · terms & refund policy.

Not ready for $13/month?

Get one real issue free, no card required — the same research, sent straight to your inbox.

Free, no card. Just one real issue in your inbox.