Track BSE-listed portfolio stocks — automatically crawl, extract, classify,
sectionize, and diff corporate announcements.
Zero external services. Pure Python + SQLite.
Quick Start · Architecture · Usage · Dashboard · Testing
If you hold Indian equities, you've seen this pattern:
- A stock you own files an audit report, related-party transaction, or shareholding change.
- You don't notice until weeks later (if at all).
- By then the price has already moved.
DiffIQ automates the observation layer. It polls the BSE Corporate Announcements API daily, downloads the attached PDFs, extracts text, classifies them by type, splits them into logical sections, and diffs each section against the prior filing of the same type — so you can spot what actually changed without reading the whole document.
┌──────────────────────────────────────────────────┐
│ Pipeline │
│ │
│ Crawler → Download → Extract → Classify → Store │
│ ↓ │
│ Sectionize → Diff │
└──────────────────────────────────────────────────┘
↓
┌─────────────────────────────┐
│ SQLite (stocks, filings, │
│ sections, diffs) │
└─────────────────────────────┘
↓
┌─────────────────────────────┐
│ Streamlit Dashboard │
│ (portfolio overview, │
│ filing browser, diff UI) │
└─────────────────────────────┘
| Step | Component | What happens |
|---|---|---|
| 1 | crawler.py | Calls BSE API via the bse package for each watchlisted stock. Returns a manifest of announcements (UUID, subject, date, PDF URL). |
| 2 | pipeline.py | Orchestrates: inserts new filings (QUEUED), then downloads PDFs in parallel (3 workers). |
| 3 | extractor.py | Downloads each PDF via httpx, extracts text with pypdf. Rejects PDFs >50MB and scanned PDFs (text <100 chars). Caps stored text at 1MB. Returns ExtractionResult. |
| 4 | classifier.py | Classifies filing subjects into types (AUDIT_REPORT, FINANCIAL_RESULT, RPT, PROMOTER_CHANGE, BOARD_OUTCOME, ROUTINE) using pre-compiled regex patterns. |
| 5 | extractor.py (2nd pass) | Splits extracted text into logical sections by filing-type-specific header regexes (e.g. "Opinion", "Basis for Opinion" for audit reports). Falls back to generic numbered-section patterns, then to a single "Body" section. |
| 6 | differ.py | Aligns new sections to the most recent same-type filing using difflib.SequenceMatcher (header similarity first, then text preview). Generates unified diffs for aligned sections. Stores diff records marked changed if output >50 chars. |
| 7 | dashboard/ | Streamlit app with portfolio overview grid, stock selector, filing expanders, section diff UI. |
- stocks — watchlist entries (bse_code unique key)
- filings — announcements with status lifecycle (QUEUED → DOWNLOADING → READY | ERROR_* | NO_PDF)
- sections — extracted document sections per filing
- diffs — section-aligned unified diff records per filing pair
- Python 3.11+
- Recommended:
uv(fast pip-compatible installer)
# Clone
git clone https://github.com/AshayK003/DiffIQ.git
cd DiffIQ
# Create virtualenv
python -m venv .venv
source .venv/bin/activate # Linux/Mac
.venv\Scripts\activate # Windows
# Install
pip install -r requirements.txt
# Dev / test dependencies (pytest)
pip install -r requirements-dev.txtNote
uv users: `uv pip install -r requirements.txt && uv pip install -r requirements-dev.txt
pip install -r requirements-dev.txt` works identically and is 10-50x faster.
pytest — 97 tests should passpython -m diffiq.pipelineThis crawls all BSE-coded stocks in the watchlist, fetches new announcements, downloads PDFs, extracts text, classifies, sectionizes, and diffs. Runs sequentially per stock with a 5-second delay between BSE API calls. PDFs download in parallel (3 workers).
Schedule it via cron (Linux/Mac) or Task Scheduler (Windows):
# Every weekday at 9 AM IST (3:30 UTC)
30 3 * * 1-5 cd /path/to/DiffIQ && .venv/bin/python -m diffiq.pipelinepython -c "from diffiq.pipeline import run_backlog; run_backlog()"Re-processes any filings stuck in QUEUED or ERROR_* status. Safe to run repeatedly — checks each filing has a PDF URL before attempting.
python -c "from diffiq.pipeline import sync; sync()"Processes existing READY filings through the sectionize → diff pipeline. Useful after adding new section patterns or fixing extraction logic. Idempotent — deletes and re-creates diffs for each filing.
streamlit run dashboard/app.pyOpens a browser UI at localhost:8501. Shows:
- Portfolio Overview — grid of all stocks with filing counts and latest dates
- Stock browser — select a stock, see all filings with status badges
- Filing detail — expand a filing to see its metadata, sections, and diffs against the prior filing
- Section diff UI — sections with changes auto-expand with a green "Changed" badge and code diff view
- Watchlist management — add/remove stocks directly from the dashboard, auto-resolves BSE codes and auto-fetches filings
Edit diffiq/config.py, or use the dashboard Watchlist Management section:
STOCKS = [
{"name": "VEDL", "bse_code": "500295"},
{"name": "HDFCBANK", "bse_code": "500180"},
# ETFs have empty bse_code — skipped by pipeline but shown in dashboard
{"name": "NEXT50IETF", "bse_code": ""},
]The config file seeds the DB on first visit. After that, you can add/remove stocks via the dashboard UI — changes persist in diffiq.db.
| Setting | Default | Purpose |
|---|---|---|
PDF_DOWNLOAD_TIMEOUT |
120s | HTTP client timeout for PDF downloads |
CRAWL_DELAY_SECONDS |
5s | Delay between BSE API calls per stock |
PDF_CACHE_DIR |
data/pdfs/ |
Created automatically; currently unused by extraction |
DATA_DIR |
data/ |
Where the SQLite database lives |
diffiq/
├── __init__.py
├── config.py # Stock watchlist, paths, constants
├── crawler.py # BSE API via `bse` package
├── pipeline.py # Orchestration: run_daily_pipeline, sync, run_backlog
├── extractor.py # PDF download (httpx) + text extraction (pypdf) + section splitting
├── classifier.py # Subject-line regex classification
├── differ.py # Section alignment + unified diff generation
├── db.py # SQLite helpers (parameterized queries, batch fetch)
├── schema.py # CREATE TABLE, connection init with WAL + busy_timeout
├── dashboard_utils.py # Status badge HTML for Streamlit
dashboard/
├── app.py # Streamlit UI — portfolio, filing browser, diff viewer
.streamlit/
├── config.toml # Streamlit theme (green, sans-serif, fast reruns)
├── style.css # Stock cards, badges, diff badge styles
tests/ # 97 tests across 12 files
data/ # gitignored — runtime SQLite DB + PDF cache
pytest # 97 tests, 0 failures expected
pytest -v # verbose mode
pytest -k "sync" # run only sync-related testsconftest.py— provides adbfixture: in-memory SQLite database created fresh per test, auto-closed.- Dependency injection — all pipeline functions accept an optional
sqlite3.Connection. Tests pass an:memory:connection, avoiding filesystem writes and side effects. - Mocked externals — HTTP calls (
httpx.Client, BSE API viabse.BSE) are patched at the call site usingunittest.mock.patch. - Coverage: 10 test files covering extractors, classifiers, differ, DB helpers, pipeline orchestration, dashboard utilities, and BSE crawler.
The dashboard is a single-file Streamlit app. Deploy to Streamlit Community Cloud:
- Push your fork to GitHub
- Go to share.streamlit.io
- Connect the repo, set main file to
dashboard/app.py - No secrets or build commands needed (free tier works)
The pipeline runs locally (or on a cron server) — the dashboard is read-only from the DB.
| Symptom | Likely cause | Fix |
|---|---|---|
| "No announcements returned" for a stock | BSE API rate-limited or stock has no recent filings | Wait and retry, or check the BSE website manually |
Filings stuck in QUEUED |
Pipeline crashed before download | Run run_backlog() |
Filings stuck in DOWNLOADING |
Pipeline crashed mid-download | Run run_backlog() — or manually set to QUEUED: UPDATE filings SET status = 'QUEUED' WHERE status = 'DOWNLOADING' |
| "pypdf extraction failed" | PDF is password-protected or uses image-only content | These filings are flagged as errors; no workaround |
| "Scanned PDF" status | Text < 100 chars extracted | The PDF contains no extractable text (image scan) |
| Streamlit dashboard is empty | Pipeline hasn't been run yet | Run python -m diffiq.pipeline |
bse package import error |
Package not installed | pip install bse>=0.1.0 |
sqlite3.DatabaseError: database is locked |
Concurrent writes to the same DB file | Built-in WAL mode + busy_timeout=5000 handles most cases; ensure only one pipeline runs at a time |
Q: Does this need API keys?
A: No. BSE corporate announcements are public. The bse package talks to the undocumented but public BSE API. No signup required.
Q: Can I use this for NSE-listed stocks?
A: Not yet. The crawler uses the BSE API. NSE blocks programmatic access. A future version may pipe in NSE data through alternate sources.
Q: Why SQLite?
A: It's a local single-user tool. SQLite means zero infra: no daemon, no config, no Docker. Backup is a single file copy.
Q: What happens if a PDF is encrypted or scanned?
A: pypdf raises on encrypted PDFs (status: ERROR_EXTRACTION). Scanned PDFs produce <100 chars of text, flagged as ERROR_EXTRACTION with a "Scanned PDF" error.
Q: How do I reset the database?
A: Delete data/diffiq.db and re-run the pipeline. Tables are re-created via CREATE TABLE IF NOT EXISTS. All state is regenerated.
Two places to update:
classifier.py— add a regex pattern to_FilingClassifier._patternsextractor.py— add section header patterns toSECTION_HEADERS
Example: adding a "MERGER" type:
# classifier.py
("MERGER", re.compile(r"\bmerger|amalgamation|scheme\s+of\s+arrangement\b", re.IGNORECASE)),
# extractor.py
"MERGER": [
r"Scheme\s+of\s+Arrangement",
r"Share\s+Exchange\s+Ratio",
r"Appointed\s+Date",
],Add via the dashboard Watchlist Management section — just enter the ticker symbol (e.g. INFY) and click Add to Watchlist. The BSE scrip code is resolved automatically via bse.lookup(), and the pipeline fetches filings immediately.
If the symbol can't be resolved, a manual BSE code input appears for entry. You can also edit config.py:STOCKS to seed the initial watchlist.
- Theme colors: edit
.streamlit/config.toml - Badge styles: edit
.streamlit/style.css - Layout: edit
dashboard/app.py— it's a single file Streamlit app
AGPL v3 — see LICENSE.