Skip to content

Repository files navigation

SEC Filing Risk-Factor Diff Tracker — see exactly what changed in a company's disclosed risk factors between filings

Python 3.9+ FastAPI SQLite SEC EDGAR 10-K BYOK optional LLM layer License: MIT pytest test suite

A public, browsable dashboard that shows exactly what changed in a company's SEC risk-factor disclosures between consecutive filings — a GitHub pull-request diff, applied to prose.

Live demo →

Hosted on Render's free tier — the first request after a period of inactivity may take 30-60 seconds to wake the instance up. That's expected free-tier cold-start behavior, not a bug.


The SEC Filing Risk-Factor Diff Tracker pulls the "Risk Factors" (Item 1A) section out of consecutive 10-K filings for a fixed universe of well-known public companies, directly from SEC EDGAR, and runs a deterministic paragraph-level diff between each consecutive pair. Every risk-factor paragraph a company has ever disclosed across its ingested filing history is classified as unchanged, newly added, or removed relative to the prior filing, and rendered with the same visual grammar as a code review: additions highlighted with a + prefix, removals struck through with a prefix, unchanged text in neutral grey — never color alone, so the diff stays legible for colorblind readers. A timeline control lets you pick any two ingested periods (not just consecutive ones), a summary strip gives an at-a-glance sentence count of what changed before you read a word of the prose itself, a section-jump sidebar lets you skip straight to a specific risk topic instead of scrolling a 40-paragraph document, and an analyst-metrics panel surfaces three deterministic, citation-backed disclosure-analysis figures (textual similarity, Fog readability, section length) alongside the qualitative diff, plotted as a trend across every filing the company has, not just the two periods currently selected.

The pre-seeded universe (~38 well-known tickers) isn't a hard limit: searching a ticker that isn't indexed yet offers to import it live, straight from SEC EDGAR, using the exact same extraction/diff pipeline as the offline batch job. Beyond a single company's own history, a company's numbers are also placed against real sector peers (a percentile rank against every other same-sector company's own latest metrics — arithmetic over data already in the database, not a model output), and a full-text search reaches across every ingested filing's real Item 1A text at once.

This is a standalone project built to demonstrate four things: SEC EDGAR filing ingestion and section-level text extraction (locating a specific, inconsistently-formatted legal section reliably across dozens of different filers' HTML conventions); deterministic text-diffing and version-comparison techniques applied to prose rather than code; temporal/timeline UI design for browsing a company's disclosure history; and responsible BYOK (bring-your-own-key) API key handling for the one optional AI feature in the app.

Educational tool for exploring changes in public company risk factor disclosures. Not investment advice. Diffs are generated by automated text comparison and may contain extraction errors; always verify against the original filing. See the Disclaimer at the bottom, which is also shown unmissably in the running app itself.

Contents

Why this exists

Equity research desks and credit risk teams read risk-factor sections comparatively, not in isolation — the signal usually isn't in what's disclosed, it's in what changed since last year. A new paragraph about supply-chain concentration, a quietly dropped disclosure about a customer concentration, a suddenly much longer discussion of litigation exposure: these are early, management-hasn't-said-it-outright signals that a company's risk profile is shifting, long before it shows up in earnings. Reading two 40-page Item 1A sections side by side to find these changes by eye is exactly the kind of mechanical comparison a computer should do instead — this project takes that specific analyst technique and makes it a one-click, browsable comparison across a real company universe, with the underlying diff logic fully deterministic and inspectable.

Architecture

Two separate paths, matching the split in this project's other BYOK tools: the ingestion and diffing pipeline runs entirely offline with no LLM anywhere in it, and the optional AI narration layer runs entirely client-side, bypassing this app's own backend completely.

1. Offline ingestion + diffing — run once, populates the dataset:

flowchart LR
    A["SEC EDGAR<br/>submissions + filing HTML"] --> B["ingest.py<br/>fetch 10-Ks per company"]
    B --> C["extract.py<br/>isolate Item 1A text"]
    C --> D["diffing.py<br/>paragraph-level difflib"]
    D --> E[("SQLite<br/>tracker.db")]
    E --> F["FastAPI<br/>read-only JSON API"]
    F --> G["Frontend<br/>timeline + diff view"]
Loading

2. The optional BYOK AI summary layer — entirely client-side:

flowchart LR
    K["Your Anthropic API key<br/>(typed into the browser)"] -->|"held in a JS variable,<br/>never persisted"| H["Browser fetch()"]
    G2["Frontend<br/>(already-rendered diff)"] --> H
    H -->|"direct HTTPS request"| A2["api.anthropic.com"]
    A2 --> H
    H --> R["Plain-English summary<br/>rendered in the diff view"]

    style H fill:#e8f0fc,stroke:#2a78d6
Loading

Notice what's not in the second diagram: this project's own FastAPI server. There is no passthrough endpoint or proxy — the request goes straight from your browser to Anthropic.

3. An optional local-LLM summary layer — precomputed offline, served to everyone:

flowchart LR
    D[("SQLite<br/>diffs table")] --> S["summarize_local.py<br/>one pass over every diff"]
    S -->|"HTTP, localhost only"| O["Ollama server<br/>Qwen3 8B"]
    O --> S
    S --> L[("SQLite<br/>local_summaries table")]
    L --> F["FastAPI<br/>/diff response"]
    F --> G3["Frontend<br/>labeled 'Local AI summary'"]
Loading

This is the offline-batch counterpart to the BYOK layer above, not a replacement for it: BYOK stays live and per-user because it needs your key, while this one runs once, against a model you host yourself, and the result is stored and served to every visitor regardless of whether they have a key. It follows the same rule as the diffing pipeline itself — nothing LLM-shaped runs in the live request path, only a read of an already-computed row. See backend/app/summarize_local.py and Local AI summaries below for how to run it.

Installation / running locally

Requires Python 3.9+. No Docker, no build step for the frontend, no separate database server.

git clone https://github.com/divyaanshkumar24/SEC-Filing-Risk-Factor-Diff-Tracker.git
cd SEC-Filing-Risk-Factor-Diff-Tracker

python3 -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install -r requirements.txt

# Populate the SQLite database from SEC EDGAR (takes a few minutes — this is
# a polite, rate-limited batch job that identifies itself to SEC per their
# fair-use guidance; it is NOT part of the live app's request path).
python -m backend.app.ingest

# Run the app — one process serves both the JSON API and the static frontend.
uvicorn backend.app.main:app --reload --port 8420

Open http://localhost:8420 in a browser. That's the whole setup — no environment variables required to get a fully working, no-key experience.

To turn on the optional AI summary layer: click the "AI summaries: off" button in the top-right of the running app and paste in your own Anthropic API key. No restart, no config file — the key lives only in that browser tab for that session. See Data handling & privacy.

Re-running the ingestion: python -m backend.app.ingest --reset drops and recreates the database from scratch, re-fetching current filings. This project intentionally does not auto-refresh on a schedule — re-run it whenever you want an updated snapshot.

Local AI summaries (optional)

A second, independent AI feature from the BYOK one above: a plain-English summary generated once per diff by a local model and stored, so it's visible to every visitor without them needing an API key. Requires Ollama running on your own machine (this is not something this project can run for you — an 8B model needs real local compute):

ollama pull qwen3:8b
ollama serve                       # if it isn't already running

python -m backend.app.summarize_local

The script walks every precomputed diff, skips any that already have a stored summary (safe to re-run after ingest.py adds new filings), and stores the result in a local_summaries table — never generated inside a live request. If it's already been run, the diff page shows a "Local AI summary (qwen3:8b, precomputed)" panel, clearly distinguished from the live, per-user BYOK panel above it. Skip this section entirely if you don't want it — everything else in the app works identically without it.

Testing

pytest backend/tests/ -v

Covers the Item 1A section-isolation heuristic against synthetic filing fixtures (test_extract.py), the diff/analyst-metrics functions against hand-computed inputs (test_diffing.py), and every route in main.py via FastAPI's TestClient against a throwaway seeded SQLite DB (test_api.py) — nothing touches the real tracker.db. Runs in CI on every push/PR (.github/workflows/tests.yml).

Quickstart

The app is a persistent split-pane shell, not a home page + detail page: a left rail (search, sector filter, sort, and the full company list) stays on screen the whole time, next to a main panel that swaps between the overview and whichever company you're reading — closer to a PR file list next to its diff than to separate site pages, since jumping between companies is the thing you actually do repeatedly. Below ~860px the rail becomes a collapsible drawer under a toggle bar.

  1. The overview (what you land on) leads with a real excerpt from the biggest recent mover's actual diff, and a "biggest recent changes" strip below it.
  2. Search the rail for a ticker or name; it filters the list live. A search with no local match offers to import that ticker live from SEC EDGAR — the same fetch/extract/diff pipeline as the offline ingestion, just scoped to one company and run on request. Search filing text (link below the rail's search box) instead searches the real extracted text of every ingested filing at once, e.g. "cybersecurity" or "supply chain."
  3. Click a company in the rail to open its detail view in the main panel — the rail stays put and highlights your selection. Two trend charts show textual similarity and Fog readability across every filing pair the company has, not just the pair currently selected.
  4. Use the two period dropdowns (or click a dot on the timeline) to pick which two filings to compare — any two, not just consecutive ones.
  5. Disclosure volume by section shows real word counts grouped under the filing's own actual section headings — not a score, just where the text (and the change) concentrated.
  6. Read the diff: added paragraphs highlighted in blue with a +, removed paragraphs in amber with a and a strikethrough, unchanged paragraphs in neutral grey. The summary strip above it gives the sentence-level added/removed counts and links to both original filings on SEC EDGAR.
  7. Use the Jump to section sidebar to skip directly to a specific risk topic instead of scrolling the whole document — it highlights your current section as you scroll. The Analyst metrics panel above it gives textual similarity, Fog readability, and section length (see How the diffing works), plus — when at least 3 other companies share the sector — a real percentile rank against those peers' own latest metrics.
  8. If you've entered an Anthropic key, a live plain-English AI summary appears in its own panel. If summarize_local.py has been run, a separately-labeled precomputed local-model summary appears too — both real, both clearly attributed to their actual source.
  9. Toggle light/dark theme from the circular button in the rail header at any time.

How the diffing works

Extraction (backend/app/extract.py): each 10-K's primary HTML document is rendered to plain text with paragraph breaks preserved (block-level HTML tags become line breaks before stripping markup, so the diff has paragraph boundaries to work with). The Item 1A section is then isolated with a two-tier heuristic — first, look for a heading that occupies an entire line by itself ("Item 1A. Risk Factors.") since a table-of-contents entry for the same item is a different standalone line, and pair it with the nearest following end-of-section heading ("Item 1B. Unresolved Staff Comments.") that's far enough away to be real prose rather than an adjacent TOC entry. Filers that run the heading directly into the first sentence of body text ("RISK FACTORS. The following discussion...") are caught by a fallback that looks for that pattern in ALL CAPS specifically, since real section headers are stylized that way while inline cross-references to "Item 1A" elsewhere in the document are not. If neither heuristic produces a section within a plausible length range, that filing is skipped entirely — no garbled or truncated text is ever stored.

Normalization: whitespace is collapsed, and short header-like lines (a bolded risk-topic title that lost its styling when HTML was stripped) are merged into the paragraph that follows them, so the diff doesn't awkwardly split a title from its own body text.

Diffing (backend/app/diffing.py): Python's difflib.SequenceMatcher runs over the two filings' paragraph lists — the prose analogue of comparing line-by-line in a code diff. Each opcode is classified: equal paragraphs render unchanged, insert paragraphs render added, delete paragraphs render removed, and a replace (an edited paragraph) renders as a removed old paragraph immediately followed by an added new one — exactly how a GitHub PR shows an edited line as delete-then-insert, rather than as an opaque "changed" block. Sentence-level added/ removed counts for the summary strip are computed with a lightweight sentence splitter applied only to the added/removed paragraphs, giving a more granular magnitude signal than a raw paragraph count.

This is deliberately not a complex NLP model — a fixed universe of well-formatted legal filings is exactly the case where deterministic text alignment gets you a correct, reproducible answer without the opacity of a learned model.

Analyst metrics (backend/app/diffing.py): three deterministic text-analysis figures, computed once at ingestion time alongside the diff itself, drawn from published equity-research and disclosure-analysis methodology — no LLM involved in any of them:

Metric What it measures Methodology
Textual similarity Cosine similarity of the two filings' word-frequency vectors, as a percentage The standard year-over-year 10-K "document similarity" measure in academic finance research — Cohen, Malloy & Nguyen (2020, "Lazy Prices," Journal of Finance) use it as a predictor of future stock returns; Brown & Tucker (2011, Journal of Accounting Research) apply it to MD&A modifications. A low score means the language was substantially rewritten, not lightly edited.
Fog readability Gunning Fog index (0.4 × ((words ÷ sentences) + 100 × (complex words ÷ words))) for each filing, plus the change A standard disclosure-complexity measure in accounting literature — Li (2008, Journal of Accounting and Economics) links harder-to-read 10-Ks to lower and less persistent earnings. Complex-word and sentence counts use a syllable-counting heuristic, standard practice for automated Fog calculators.
Item 1A length Word count of the section, and % change vs. the prior filing A simple, practitioner-level volume signal — a risk-factor section growing sharply is itself often worth noticing, independent of what specifically changed.

These are simplified, deterministic implementations inspired by the cited methodology — not a reimplementation of any paper's full model — consistent with how the rest of this project's diff logic favors transparent, reproducible arithmetic over a black box.

Data handling & privacy

  • The optional AI summary layer is bring-your-own-key. You paste your own Anthropic API key into the running app; it is held in a single in-memory JavaScript module variable (frontend/js/state.js) for that browser tab's session only — never written to localStorage, sessionStorage, a cookie, a database, a log file, or disk anywhere. Reloading the page clears it.
  • This app's own backend never sees your key. The summary call (frontend/js/llm.js) goes directly from your browser to https://api.anthropic.com — there is no server-side proxy or passthrough endpoint in this project at all, by design.
  • Everything else works with no key at all. The company universe, every filing, every precomputed diff, and every summary statistic is served from the local SQLite database — the AI layer only adds an optional paragraph on top of data you can already see and read yourself.
  • No user accounts, no tracking, no saved preferences. There is nothing to opt out of because nothing is collected.
  • The local AI summary layer never leaves your machine. summarize_local.py talks only to 127.0.0.1 (your own Ollama server) — no filing text is ever sent to a third party for it. The resulting summary text is the only thing stored, in your own local tracker.db.

Design principles

Substantive change over noise. Whitespace, boilerplate formatting, and page-break artifacts are normalized out before diffing so what you see reflects a real change in disclosed language, not a formatting difference.

Graceful degradation without a key. No API key → the app is still fully functional, just without the AI paragraph. A filing whose Item 1A section can't be reliably isolated → that filing is skipped, not shown as broken or garbled text.

Readable prose over dense data. Typography is a first-class design concern here: a serif reading font, comfortable line-height, and constrained line length for the diff body, because this is a reading-heavy tool being judged on how comfortably you can read a 40-paragraph prose diff.

Diffs describe language, not implications. Every added/removed paragraph is presented as exactly what it is — a change in disclosed text — never as a prediction, a rating, or an accusation. The AI summary layer is explicitly instructed the same way.

Accessible by more than color. Every added/removed paragraph carries a +/ prefix and (for removals) a strikethrough in addition to its background color, and the diff palette uses blue/amber rather than red/green so it stays legible for the most common forms of color vision deficiency.

Metrics inform, never conclude. The analyst-metrics panel presents its figures with their methodology cited in the UI itself; none of them roll up into a single score or a verdict — a low similarity score or a rising Fog index is a prompt to go read the diff, not a conclusion in itself. The same discipline applies to the newer panels: peer percentiles are labeled as arithmetic against real stored peer data, not a rating; the section breakdown is real word counts under the filing's own real headings, never scored or color-graded by "severity." If a number is shown, it traces back to a real computation over real stored data — nothing is invented to make a panel look fuller.

API overview

Method Endpoint Description
GET /api/health Liveness check + whether the database has been populated
GET /api/meta Dataset "data as of" date and ingested company count
GET /api/companies List every company with >=2 ingested filings, with a latest-change summary badge
GET /api/companies/search?q= Search locally-indexed companies plus SEC's full ticker map, so a match that isn't indexed yet can be offered for import
POST /api/companies/{ticker}/import Fetch, extract, and diff every available 10-K for a ticker outside the pre-seeded universe
GET /api/companies/{ticker} Company detail: all ingested filing periods with dates/source URLs, plus a trend array (similarity/Fog/word-count per consecutive filing pair) for the trend charts
GET /api/companies/{ticker}/diff?from_id=&to_id= Precomputed paragraph-level diff (each chunk flagged heading: true/false) + summary + analyst_metrics + peer_percentile (null with <3 sector peers) + section_breakdown + local_summary (null until summarize_local.py has run) between two periods
GET /api/search/text?q= Full-text search over every ingested filing's real Item 1A text, with a highlighted snippet per matching company

This backend never accepts or forwards an Anthropic API key — see Data handling & privacy. The AI summary call happens entirely in the browser, outside this API surface.

Tech stack

Python FastAPI SQLite SEC EDGAR 10-K Python difflib Vanilla JS Anthropic API BYOK

text-diffing · sec-edgar · risk-factor-analysis · document-versioning · byok · fastapi · sqlite · open-source

  • Backend: Python + FastAPI, serving pre-computed data from SQLite over a small read-only JSON API. No ORM — plain sqlite3 with hand-written, parameterized queries.
  • Ingestion: a standalone script (backend/app/ingest.py) that pulls SEC EDGAR's submissions API and each filing's primary HTML document, run offline — never in the live request path. backend/app/acquisition.py reuses the same fetch/extract/diff functions to scope that pipeline to a single on-demand ticker, callable from the live API.
  • Extraction & diffing: dependency-light Python (backend/app/extract.py, backend/app/diffing.py) using BeautifulSoup for HTML-to-text and the standard-library difflib for comparison — no NLP model involved.
  • Frontend: vanilla HTML/CSS/JS with ES modules, no build step, no framework — a persistent split-pane shell (frontend/js/main.js) with a company rail that never unmounts and a hash-routed main panel, serif/mono typography, and a colorblind-conscious diff palette. The trend charts (frontend/js/chart.js) are hand-rolled themed SVG, not an external charting library — there are never more than a handful of data points, and inline SVG lets the marks follow the app's own CSS custom properties (and light/dark theme) for free.
  • Database: SQLite — a single file, no server process, trivially inspectable with any SQLite client.

Deployment

Live at https://sec-filing-risk-factor-diff-tracker.onrender.com/, deployed on Render's free tier. See DEPLOYMENT.md for the full reasoning: why a long-lived-process host (Render/Railway/Fly.io) is the natural fit for this single-process, read-only-SQLite architecture over serverless, the Render vs. Railway cost comparison, and the exact deploy steps.

Disclaimer

Educational tool for exploring changes in public company risk factor disclosures. Not investment advice. Diffs are generated by automated text comparison and may contain extraction errors; always verify against the original filing. AI-generated summaries, when enabled, are not guaranteed to be accurate.

This disclaimer is also shown, unmissably, in the running app itself (footer on every page).

License

MIT — see LICENSE.

About

See exactly what changed in a company's SEC risk-factor disclosures between filings

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages