Investor Legends & Smart Lists: Published Criteria, Tested One at a Time
Eight screens built from criteria famous investors put in print, run weekly over GMI's graded universe, with a pass, fail or "not evaluated" for every criterion on every company. Any screen can then be saved as a list that keeps itself current and logs why each symbol came or went.
BUILT · SEPTEMBER 2026 In final testing ClientWeb · AdminWeb · MAUIWhat Wave 25 Built
Investor Legends are eight pre-built screens. Each one names its source book, and each company gets a per-criterion breakdown: "Passes 6 of 7 Graham tests; fails dividend record: 14 years, needs 20." Smart Lists turn any screen, a legend or a custom v1 screen, into a symbol list whose membership refreshes on a schedule, with every entry and exit logged with its reason: "NKE left: PEG rose to 1.4 (needs below 1)."
It extends the Wave 16 stock screener. There is still one
screener (ScreenerService, ScreenerEngine, the presets table), not a
parallel one, and the v1 presets that read as judgments were renamed while the old keys kept
resolving: Undervalued Large Caps became Low P/E Large Caps and
Dividend Champions became 3%+ Yield, Moderate Debt, so saved links
still work.
The Eight Screens and What Each Can't Test
| Screen ("Inspired by …") | Source | A sample of what's tested | Not evaluated or informational |
|---|---|---|---|
| Peter Lynch | One Up on Wall Street (1989) | PEG under 1; 5-year EPS growth 15–30%; debt/equity ≤ 0.33; net cash per share > 0; inventory not outgrowing sales | Knowing the story, the company category, the product: judgment calls. Inventory is "not applicable" when a company holds none |
| Benjamin Graham (defensive) | The Intelligent Investor, ch. 14 | Revenue ≥ $750M; current ratio ≥ 2; 10 years of positive earnings; 20 years of dividends; P/E × P/B ≤ 22.5 | Price-based tests for foreign-currency filers |
| Buffett-style quality | Berkshire letters; Hagstrom; Buffettology | ROE > 15% in 8 of 10 years; debt ≤ 5× net income; steady operating margin; positive, growing free cash flow | Management quality and the durability of a competitive advantage. Buffett never published a numeric screen, so this one follows Hagstrom's and Buffett & Clark's readings |
| Greenblatt Magic Formula | The Little Book That Beats the Market | Combined earnings-yield and return-on-capital rank in the top 30; financials and utilities excluded | Nothing; ranks 31–60 count as near misses |
| Piotroski F-Score | Journal of Accounting Research (2000) | The nine binary tests; a match is 8 or 9 | Nothing; book-to-market is shown as information only |
| John Neff | John Neff on Investing | P/E ≤ 60% of the universe median; total-return ratio ≥ 2× median | Cyclicality, "growing field", fundamental case: judgment calls |
| David Dreman | Contrarian Investment Strategies | Bottom-fifth P/E among the 1,500 largest; debt below sector median | Price-based tests for foreign-currency filers |
| William O'Neil CAN SLIM | How to Make Money in Stocks | C, A, N (near 52-week high), L (relative strength ≥ 80th percentile) | I (no licensed institutional-ownership data), M (a whole-market call, not a company test), N's new products |
Every screen is labelled "Inspired by", with a card disclaimer: GMI's interpretation of published criteria, not endorsed by or affiliated with the investor. Two thresholds are GMI's own readings and are listed as open questions in the design doc: Graham's 1971 "$100M of sales" CPI-adjusted to $750M, and Lynch's balance-sheet ideal expressed as debt/equity ≤ 0.33.
Decision: Four States, Not Two
A pass/fail screen has to do something with a criterion it can't compute. The two easy choices are both wrong. Treating it as a pass inflates matches: every CAN SLIM candidate would "pass" institutional sponsorship. Treating it as a fail hides good companies behind a data gap: every bank would fail Graham's current-ratio test because banks don't report current assets.
So every criterion resolves to Pass, Fail, Not evaluated (with the reason printed, e.g. "statements reported in TWD; GMI does not convert currencies for price-based tests") or Not applicable. A company matches when every required criterion passes. A near miss fails exactly one, and that one has to be a real Fail: a company one "not evaluated" away from matching isn't shown as almost there, because we don't know that it is.
The near-miss view turned out to be the teaching surface. "Fails dividend record: 14 years, needs 20" explains Graham's standard better than the list of companies that pass it.
flowchart TB
subgraph SEO[gmi-seopagebuilder - weekly when idle]
P1[Phase 1: metrics per company<br/>cached statements + prices]
P2[Phase 2: universe statistics<br/>medians, percentiles,<br/>Magic Formula ranks, RS]
P3[Phase 3: evaluate 8 legends<br/>Pass, Fail, Not evaluated,<br/>Not applicable]
P1 --> P2 --> P3
end
FMP[Cached market-data client] --> P1
DEEP[ForceStatementRefetch<br/>one-time deep fetch] -.-> FMP
ST[(investorlegendsymbolstate)] -.-> DEEP
P3 --> R[(investorlegendresults)]
R --> API[Legends API]
API --> APPS[ClientWeb, AdminWeb, MAUI]
subgraph SCH[gmi-schedulesengine - SmartListPass]
DUE[Due Smart Lists] --> RUN[Re-run the screen]
RUN --> SEL[SmartListMath<br/>free-tier selection + diff]
end
R --> RUN
SEL --> SL[(symbollists<br/>real membership)]
SEL --> CH[(smartlistchanges<br/>reason per entry and exit)]
CH -. shaped for .-> AL[Alerts detector<br/>not wired yet]The Cache Said "Complete" at 4 Years for a 22-Year Question
Graham needs 20 years of dividends, and his 10-year EPS test needs 13 years of statements. The pass asks the cached market-data client for 22 annual periods. For most symbols it got back four.
The statement cache treats a window as complete when every period it holds is settled. That rule is deliberate: without it, a company with only three years of history would be refetched on every request, forever. Its side effect is that a symbol the SEO builder had warmed at 4 periods answers a 22-period request with those same 4 rows, and it looks like a young company rather than a cache miss.
| Option | Why not / why |
|---|---|
| Change the completeness rule | It changes behaviour for every consumer of the cache and brings back the refetch-forever problem for young companies |
| Invalidate statements for the whole universe | An admin-scale action that throws away settled data and costs the same calls |
| A scoped, one-time deep fetch (chosen) | CachingFmpApiClient.ForceStatementRefetch() returns an IDisposable scope backed by an AsyncLocal flag. Inside it, statement reads skip the cache; the write path is unchanged, so the deep history is cached exactly as a normal miss would cache it. The pass records the deep fetch in investorlegendsymbolstate and repeats it only if the cache later holds fewer periods than it saw |
AsyncLocal matters here: the pass processes symbols concurrently, and a static flag
would leak the bypass into unrelated requests. The cost is budgeted rather than hoped for: about
10k provider calls once (three deep statement calls plus a price call for ~2.5k
companies), then roughly one price call per company per week. A config switch
drops the price calls entirely; CAN SLIM's price tests then become "not evaluated" instead of
running on stale prices.
Twenty Years of Data Has Twenty Years of Corporate Actions
Short-window features mostly never meet these problems. A 10- and 20-year screen meets all of them:
- Splits in EPS history. The provider's annual EPS isn't guaranteed to be split-adjusted, and a 4-for-1 split looks like a 75% earnings collapse.
LegendMetricsBuilder.DetectSplitFactorstreats a jump in diluted shares near a split ratio, with net income roughly unchanged, as a split and restates earlier EPS. A stock-funded merger, where earnings jump along with the share count, is deliberately not restated. - Splits in cached prices. The price cache is append-only (
INSERT IGNORE), so pre-split closes can sit beside post-split ones. A one-day move within 3% of a split ratio restates the earlier closes before the 52-week-high and 12-month-return tests run. This was found in the post-merge hardening pass, not in the first build. - Foreign filers. Statements come in the reporting currency; price is in USD. Dividing one by the other is how GMI once produced a $50-trillion Taiwanese company. Tests that mix the two are marked "not evaluated" with the currency named, rather than being run on FX-skewed numbers.
- Banks and insurers. Where current assets and liabilities aren't reported, current-ratio tests are "not evaluated", not failed.
The same hardening pass fixed a quiet bug: the precompute cleared the whole EF change tracker between batches, which also detached the run row, so progress and status saves silently stopped. It now detaches per entity type. A pass that can't start (typically a migration not yet applied) now waits an hour instead of logging an error on every 60-second builder poll.
Decision: A Smart List Is a Real Symbol List
The tempting design was a virtual list: a saved query evaluated when someone looks at it. Instead, a
Smart List is an ordinary symbol list plus a smartlists row that owns
its membership. Because the backing list is real, three existing systems work with no special case:
- tracked-symbol counting (
AccountEntitlementService) counts it like any list; - a recurring report on the list reports its current membership, because the report engine reads list items when the job runs;
- add-to-list, dashboards and one-off reports just work.
A refresh re-runs the screen (legend lists read the stored results; custom lists run
ScreenerEngine over the graded universe), diffs old against new with a pure
SmartListMath.Diff, and writes each change to smartlistchanges with a
reason. Legend exits name the first failing criterion and its value; custom exits name the first
failing filter ("P/E 16.2 is above the screen's 15"). A refresh never empties a list
because data is missing: no completed legend pass, or an empty universe, skips the refresh
with a status instead of logging every member as an exit.
Free tier: the account-wide cap is 8 distinct tracked symbols, so a free Smart List keeps pinned symbols that still match, then the top-ranked matches, and never pushes the account's distinct total above 8. A symbol already tracked on another list costs nothing. A symbol that still matches but falls outside the cap is logged as an exit with that reason ("still matches; outside the free tier's top 8"), and the page says so: "Showing 8 of 23 matches — free tier." Paid lists have a safety ceiling of 500.
The Change Log Is Shaped for Alerts
Wave 25 was built in parallel with Wave 24 Alerts, so the change
rows were designed as alert source records from the start: EventType
(ScreenEntry / ScreenExit), Symbol, the owning
SymbolListID, a PayloadJson with the reason, a unique
DedupeKey, and AlertEventID / AlertProcessedDateTime left
NULL for a detector to claim.
The honest status: the seam is in the schema, but no alerts detector reads it yet. "A symbol entered your screen" is not an alert anyone can subscribe to today. Wiring it is a small detector, not a redesign, and that's the point of shaping the rows now.
Schema, Tests and What It Doesn't Do
| Area | Detail |
|---|---|
| Tables (5) | investorlegendruns, investorlegendresults (per symbol × legend: pass counts, match and near-miss flags, sort score, one-sentence summary, per-criterion JSON), investorlegendsymbolstate, smartlists, smartlistchanges |
| Endpoints (10) | Legends list, legend search, a company's breakdown across all eight; Smart List CRUD, refresh-now and history. Account-scoped with the admin-or-owner check; nothing under the Functions host's reserved admin/ prefix |
| Tests (44) | Piotroski's nine tests on known answers, Greenblatt ranking, Graham's 22.5 product and near-miss summary, Lynch PEG, Neff total-return ratio, Dreman bottom fifth, CAN SLIM's not-evaluated parts, split detection in EPS and prices, foreign filers, Smart List selection, diff and free-tier cap, an end-to-end precompute over a mocked provider, and a voice test that fails on "buy", "should", "undervalued" or "gem" in any screen text |
| Deliberately not built | Screen backtesting (it needs point-in-time universes and a survivorship-bias disclosure first); a public SEO teaser page; plain-English screening via Claude |
Descriptive voice only: a company "passes 5 of 6 tests", "matches" or "is a near miss". Never "buy", "undervalued" or "gems". A screen that matches a published rule is a description of the company's numbers, not a recommendation.