A Graham/Buffett-style value screen built on SEC EDGAR fundamentals.
Scores a company against thirteen quality criteria and three valuation criteria, reports the two separately, and runs a conservative DCF.
Needs uv. SEC rejects anonymous traffic, so the User-Agent must name a real contact — there is no default.
git clone https://github.com/xiaomei-wu/stockscreen.git
cd stockscreen
uv sync
export SEC_USER_AGENT="Your Name you@example.com"
uv run python -m stockscreen AAPL
uv run python -m stockscreen AAPL MSFT KO --years 10
uv run python -m stockscreen AAPL --json > aapl.json
uv run python -m stockscreen AAPL --failures-only
uv run pytest # 144 tests, fully offlineQUALITY 12/13
✓ FCF trend +7.1%/yr > 0 positive 10/10 yrs, median margin 25.1%
✓ Share count -31.8% shrinking SBC 9.3% of FCF — real
✗ Total payout / FCF 104.4% < 100%
⚠ ROE 110.6% > 15% equity shrunk by buybacks — read ROIC (64.2%) instead
...
Quality 12/13 · Valuation FAIL
→ Good business, wait for price
Quality and valuation are never blended into one number. A great company at a bad price is a wait, not a buy, and a single score hides exactly that.
Each is a pure function of the one before.
| Layer | Module | Touches network |
|---|---|---|
| ingest | ingest/edgar.py, ingest/prices.py |
yes — and only here |
| normalize | normalize.py |
no |
| derive | derive.py, technicals.py |
no |
| score | score.py, valuation.py |
no |
| report | report.py |
no |
derive, score and valuation do no I/O, which is why the whole test suite
runs offline in under a second.
SEC requires a real User-Agent. A browser-style string gets a 403 that reads
like a network fault, so the client refuses to start without one — set
SEC_USER_AGENT="Your Name you@example.com" or pass --user-agent. Rate limit
is 10 req/s; the client stays under it.
XBRL tags drift between filers and over time. Apple reports revenue as
SalesRevenueNet through FY2017 and
RevenueFromContractWithCustomerExcludingAssessedTax after — neither tag alone
spans ten years. tags.py resolves every field through a per-year coalesce
chain, falling back to summed components where no filer-level aggregate exists
(Apple has no DebtCurrent; its short-term debt is commercial paper plus the
current portion of long-term debt).
Per-share figures mix share bases across a split. EDGAR reports each year on
whatever basis was current when it was last filed. Apple's FY2017 share count
was last filed in 2018, before the 2020 4-for-1 split, so raw data shows the
share count rising 5.5B → 15.0B — turning the largest buyback program in
history into apparent dilution. normalize.py corrects this using each fact's
filing date against the split history. Verified by an independent identity:
eps × shares reconciles to reported net income in every year.
Calibrated for mature, profitable, cash-generative businesses. It will:
- reject high-growth companies — negative FCF during expansion
- refuse to score banks and insurers — they tag neither EBIT nor capex, and D/E is meaningless when debt is the product
Both are by design, not by accident. The screen says so in its data notes rather than producing a confident wrong answer.
A screen narrows a list; it does not make a decision. Every threshold here is a judgement call encoded as a number, and the DCF is a way of writing down assumptions rather than a prediction.