How to Detect Risk Factor and MD&A Changes Across SEC Filings
You want to know what changed in a company’s risk disclosures since last year, but your extractor hands you 30 pages of boilerplate and no clean way to diff them.
In this article, you will learn:
- Why risk factor and MD&A diffing is one of the most reliable text signals in equity research — and one of the least implemented
- The 5 extraction failures that make year-over-year comparison impossible with a regex parser
- A paragraph-level fuzzy-matching approach that catches additions, removals and material rewrites
- How to catch mid-year risk updates in 10-Q filings that annual-only monitoring misses entirely
- MD&A tone and vocabulary change detection across fiscal periods
- And lastly, how to track risk factor changes year over year across a universe — with a free API
Nobody reads a risk factors section for the risks. You already know the company faces competition, depends on key personnel, and could be harmed by macroeconomic conditions. Item 1A is 30 pages of lawyer-drafted boilerplate, and read once, in isolation, it tells you almost nothing. Read against last year’s version, it tells you a great deal — a new paragraph about customer concentration means counsel decided it had to be disclosed, a supply-chain risk added in year 3 of a relationship means the relationship changed, and a quietly removed risk is signalling something too.
The same applies to MD&A, where the words that appear, disappear, or soften are among the earliest observable indicators of a change in the business — often preceding the numbers by a quarter or more. Risk factor and MD&A change detection is one of the best-documented text signals in equity research, and one of the least implemented, for an entirely mechanical reason.
Why this analysis almost never gets built
Because you cannot diff what you cannot cleanly extract.
To compare this year’s Item 1A with last year’s, you need this year’s Item 1A and last year’s Item 1A as clean, bounded text. Not “the 10-K.” Not “roughly the risk section, plus some of the table of contents, minus wherever the regex cut it short.”
And extracting Item 1A reliably is genuinely hard:
- The table of contents matches your anchor first. Every 10-K contains the string “Item 1A. Risk Factors” in its TOC, hyperlinked, before the real section starts.
- The heading is not contiguous text. In modern inline-XBRL filings, headings are split across nested styled elements. In the raw HTML,
Item 1A. Risk Factorsfrequently does not exist as a matchable string. - Filers write it differently.
Item 1A.,ITEM 1A —,Item 1A:, with non-breaking spaces between every token. - The end anchor is unreliable. Item 1B (Unresolved Staff Comments) is often a single line reading “None,” and sometimes omitted entirely — at which point your Risk Factors section runs to the end of the document.
- The item keys differ by form. Risk Factors are
item1ain a 10-K butpart2_item1ain a 10-Q andItem3Din a 20-F.
Any single one of these is manageable. Together they produce a parser that fails silently — you get a string back, it is simply the wrong string, and your diff now reports 50 “new” risk factors that are actually a table-of-contents fragment.
So the analysis gets scoped, estimated, and shelved.
The cost of the free approach
You can build every bit of this from free EDGAR data. I want to be straight with you about the bill, though, because it arrives in 3 separate instalments and none of them show up on day one.
Rate limits. The SEC caps automated access at 10 requests per second, per IP address, across every EDGAR domain, and enforces it with a 403 Forbidden and roughly a 10-minute block on your whole IP. Every request must declare a User-Agent naming a contact. A 10-year risk-factor panel across the S&P 500 is thousands of filings and tens of thousands of requests — hours of wall-clock time at the ceiling, and a real chance of a block partway through leaving your panel with holes you will not notice.
A parser you now own. The 5 failure modes above do not go away. They become a maintenance surface: a heuristic stack that needs a new special case every time you widen the universe, plus a regression suite, plus somebody’s time forever.
Silent corruption. This is the expensive one. A rate limit failure is loud. A parser that returns the wrong 200 characters is quiet, and it flows straight into a signal, into a backtest, into a research note.
The clean input that makes diffing possible
So the parser is the bottleneck and the free route saddles you with maintaining one forever. The alternative is to skip the parsing entirely and start with sections that are already extracted.
Business Quant’s Section Extraction API pre-extracts sections at ingestion time — 10-K, 10-Q, 8-K, 20-F, S-1 and F-1 — and serves them as clean HTML in sub-second lookups, with inline XBRL already stripped. Which turns the hard part of this analysis into a function call.
def section(accession, key):
r = requests.get(
f"https://data.businessquant.com/secfilings/{accession}/content",
params={"item_key": key, "api_key": API_KEY},
)
return BeautifulSoup(r.text, "html.parser").get_text("\n", strip=True)
And the SEC Filings API gives you the filing history, the fiscal periods, and — in the items[] array of each record — confirmation of which sections that filing actually contains, before you request them. For a year-over-year diff, you need at least 2 filings of the same form type for the same company, sorted by report period. The code below pulls the full 10-K history and sorts it chronologically so you can pair any 2 consecutive years.
r = requests.get("https://data.businessquant.com/secfilings", params={
"ticker": "AAPL", "formtype": "10-K", "page_size": 1000, "api_key": API_KEY,
})
filings = sorted(r.json()["data"], key=lambda f: f["reportperiod"])
That is the entire input layer. Clean, bounded text in, diff out.
Year over year risk factor diffing
Now for the good part. Risk factors are paragraph-structured, and that is precisely what makes them diffable — lawyers add and remove whole paragraphs rather than editing sentences. So you split into paragraphs, then match each paragraph in this year’s filing against its nearest neighbour in last year’s. Anything without a close match is news.
import difflib
def paragraphs(text, min_len=120):
return [p.strip() for p in text.split("\n") if len(p.strip()) > min_len]
def risk_delta(prior_text, current_text, threshold=0.75):
prior, current = paragraphs(prior_text), paragraphs(current_text)
def best_match(p, pool):
return max((difflib.SequenceMatcher(None, p, q).ratio() for q in pool), default=0)
added = [p for p in current if best_match(p, prior) < threshold]
removed = [p for p in prior if best_match(p, current) < threshold]
return added, removed
fy24 = section(filings[-2]["accession"], "item1a")
fy25 = section(filings[-1]["accession"], "item1a")
added, removed = risk_delta(fy24, fy25)
print(f"{len(added)} new risks, {len(removed)} dropped risks")
The similarity threshold matters. At 0.75, a paragraph that was lightly reworded counts as unchanged, while a materially rewritten one registers as new. Tune it against a set of filings you have read — too high and every year is a full rewrite; too low and genuine additions get absorbed as matches.
Added paragraphs are the interesting object. Rank them by length and position — a substantial new risk placed near the top of Item 1A was placed there deliberately.
Catching mid-year risk updates in the 10-Q
Here is where most disclosure monitoring quietly fails: it only looks at annual reports. Which means that in the worst case, it is 12 months late to something the company told you about in April.
Companies are required to update risk factors in their quarterly filings when there are material changes — and that is where the timely signal lives. The key is part2_item1a, not item1a. It is under Part II, because a 10-Q has an Item 1 in both Part I (Financial Statements) and Part II (Legal Proceedings), so every 10-Q item key carries its part prefix.
r = requests.get("https://data.businessquant.com/secfilings", params={
"ticker": "AAPL", "formtype": "10-Q", "page_size": 1000, "api_key": API_KEY,
})
for filing in r.json()["data"]:
if "part2_item1a" not in filing["items"]:
continue # no material risk update this quarter
update = section(filing["accession"], "part2_item1a")
print(filing["reportperiod"], len(update.split()), "words of updated risk")
The presence or absence of part2_item1a is itself a signal. A company that files it in a quarter is telling you it had a material risk change worth disclosing. Most quarters, most companies omit it. The ones that do not are worth reading.
Hardcode item1a here and every 10-Q silently returns nothing — the single most common defect in disclosure-monitoring pipelines.
MD&A tone and language change detection
MD&A needs a different approach, and it is worth understanding why. Unlike risk factors, MD&A is written fresh each period — so paragraph matching finds differences everywhere and tells you nothing. What you look at instead is vocabulary and framing.
from collections import Counter
import re
def terms(text):
return Counter(re.findall(r"[a-z]{4,}", text.lower()))
prior = terms(section(filings[-2]["accession"], "item7"))
current = terms(section(filings[-1]["accession"], "item7"))
emerging = {w: current[w] - prior[w] for w in current
if current[w] - prior[w] >= 3 and prior[w] = 3 and current[w] <= 1}
Terms that appear from nothing — “restructuring,” “impairment,” “headwinds,” “moderation,” a new product name, a new geography — are the earliest textual trace of a change in the business. Terms that disappear are equally informative.
Layer whatever you like on top: an uncertainty or negativity lexicon, a hedging-language count, or an LLM pass asking what shifted in management’s framing. The scoring method is the easy, well-documented part. Clean, correctly-bounded MD&A text was always the bottleneck — and note that in a 10-Q, MD&A is part1_item2, not item7.
Comparing risk factors across an industry peer set
Now point the same machinery at a peer group and it answers a much better question: which of these risks are idiosyncratic, and which are simply what everyone in the sector is saying this year?
Pull item1a for every company in a peer group in the same fiscal year, then compare. A risk that appears in 8 of 10 peers is a sector condition. A risk that appears in one is a company-specific disclosure — and that is the one worth a phone call.
peers = ["AAPL", "MSFT", "NVDA", "AMD", "INTC"]
corpus = {}
for ticker in peers:
latest = requests.get("https://data.businessquant.com/secfilings", params={
"ticker": ticker, "formtype": "10-K", "page_size": 1, "api_key": API_KEY,
}).json()["data"][0]
corpus[ticker] = section(latest["accession"], "item1a")
Embed the paragraphs, cluster them, and label the clusters. Risks that cluster across the whole peer set are the sector’s shared exposure. Singletons are the alpha.
Running it across a universe
2 rules make a universe-scale run survivable. I learned both by not following them.
Distinguish “not disclosed” from “failed.” If a filer omitted a section and your pipeline errored, both must not land in the same null. Record a status alongside the text — the items[] array on each filing record tells you which sections exist, so the distinction is available before you make the call.
Exclude entity types that do not produce standard narrative sections before you run. Filter out ETF, UNIT and CEF security categories, plus SIC 6189 (asset-backed securities) and SIC 6798 (REITs). Otherwise your failure log fills with noise and you spend a day investigating filings that were never going to have an Item 1A.
Edge cases that distort the diff
The pipeline above produces clean diffs at scale. What follows are the assumptions baked into it that will mislead you if you do not account for them.
Risk factor sections grow structurally, not just informationally. Disclosure inflation is real, and Item 1A has been getting longer across the entire market for 2 decades. So when you see a company add 5 pages, resist the obvious conclusion — it is not necessarily disclosing more risk, it may just be drifting with everyone else. Normalize against a market or sector baseline before you read anything into a word count.
Reordering is not change. A filer restructuring Item 1A into new sub-headings will trip a naive line diff into reporting the whole section as rewritten. Paragraph-level fuzzy matching, as above, is resistant to reordering. Line diffs are not.
Item keys differ by form type. item1a (10-K), part2_item1a (10-Q), Item3D (20-F), RiskFactors (S-1). Use a routing map keyed on formtype and your global panel stays continuous.
Amended filings carry a new accession. A 10-K/A covering the same reportperiod may revise a section. Match amendments to originals on reportperiod, not filing date.
Trusts and unusual filers retitle sections. Royalty and structured trusts sometimes omit MD&A or retitle it. Check items[] rather than assuming a section exists.
The short version
Here is what I find genuinely odd about this whole area. Risk factor and MD&A change detection is one of the most reliable text signals in equity research, and the analysis itself — the actual clever bit — is about 100 lines of Python. You have read most of them on this page. It stays unbuilt not because it is hard, but because the input is: clean, correctly-bounded, per-section text across thousands of filings, which used to require a parser that fails silently sitting behind a rate limit that fails closed.
Business Quant’s Section Extraction API delivers that input directly: pre-extracted sections from 10-K, 10-Q, 8-K, 20-F, S-1 and F-1, clean HTML, sub-second, free. The SEC Filings API supplies the filing history and fiscal periods to align them against.
Which flips the whole thing around. The diff becomes the project. The input becomes a function call. Go and find out what your names stopped saying this year.
Frequently asked questions
Why is comparing risk factors year over year useful if the language barely changes?
Most of Item 1A is boilerplate that repeats every year, so the paragraphs that are added, removed or materially rewritten are the signal. A newly added risk means legal counsel decided something had to be disclosed; a quietly removed one is informative too.
What item key should I use for risk factors in a 10-Q filing?
part2_item1a, not item1a. A 10-Q has an Item 1 in both Part I (Financial Statements) and Part II (Legal Proceedings), so every 10-Q item key carries a part prefix, and hardcoding the 10-K key returns nothing for quarterly filings.
How do I tell if a risk factor section actually changed versus was just reorganized?
Use paragraph-level fuzzy matching rather than a line-by-line diff. A filer restructuring Item 1A into new sub-headings will trip a naive line diff into reporting the whole section as rewritten, while matching each paragraph against its nearest neighbor from last year is resistant to reordering.
Why does risk factor length keep growing across the market?
Disclosure inflation is a documented, market-wide trend — Item 1A has gotten longer for 2 decades regardless of any one company's actual risk profile. Normalize word count against a market or sector baseline before treating a longer section as more risk disclosed.
Is the Business Quant Section Extraction API free for building a risk factor diffing pipeline?
Yes. Sections across 10-K, 10-Q, 8-K, 20-F, S-1 and F-1 are pre-extracted and served as clean HTML in sub-second lookups, with a free API key and no parser required.