AI disclosure: This article was produced fully autonomously by AI agents for Stock Expert AI.
A screener that sorts only the rows on the visible page produces a ranking that looks right and is wrong. This post sketches a design that avoids it: one data source, a closed filter schema, ranking before paging, and explicit handling of instruments with no score.
It is part of the series Building a finance site with AI agents. The codebase these notes come from is an educational stock-research tool that filters actively traded US stocks by fundamentals, risk and a proprietary score. The Python below is an illustrative sketch of the design, not the production implementation.
Why use a single market-data source?
The screener's figures come from one provider, Financial Modeling Prep (FMP), and are refreshed daily. A second, derived layer is the proprietary score, which is recalculated from FMP fundamentals and price change, with no sentiment or analyst input.
The trade-off is plain:
- Upside: every column in a row shares one origin and one refresh cadence. You never have to explain why the P/E came from one vendor and the market cap from another.
- Downside: you inherit that provider's gaps and errors, and you have no second source to cross-check against.
For a screener, consistency across rows matters more than any single field being perfect. Comparing two stocks only makes sense if both numbers were produced the same way.
What does a closed filter schema look like?
The screener exposes 13 filter metrics: market cap, P/E ratio, forward P/E, revenue growth, EPS growth, EBITDA growth, net income growth, debt-to-equity, beta, volume, dividend yield, the score and sector. Fixing the set up front lets an API reject unknown keys instead of silently ignoring them.
ZERO = 0 SCORE_MAX = 100 STRONG_MOONSHOT_MIN = 80 SPECULATIVE_BELOW = 30 PAGE_SIZE_MIN = 20 PAGE_SIZE_MAX = 100 EXPORT_ROW_CAP = 500 FILTER_KEYS = ( 'market_cap', 'pe', 'forward_pe', 'revenue_growth', 'eps_growth', 'ebitda_growth', 'net_income_growth', 'debt_to_equity', 'beta', 'volume', 'dividend_yield', 'moonshot_score', 'sector', ) def validate_filters(filters): unknown = set(filters) - set(FILTER_KEYS) if unknown: raise ValueError('unknown filter keys: ' + ', '.join(sorted(unknown))) return filters Note the named constants. Magic numbers in a screener are how a score band quietly drifts out of sync between the UI and the API.
How do you rank across the full filtered set?
The rule: filter first, rank the whole matching set, then slice a page. The published behavior of the tool is that results are ranked server-side by any chosen column across the full filtered set, not just the visible page. The sketch below shows one way to make that order hard to get wrong.
def passes(row, filters): for key, bounds in filters.items(): value = row.get(key) if value is None: return False if key == 'sector': if value != bounds: return False continue low, high = bounds if low is not None and value < low: return False if high is not None and value > high: return False return True def rank_all(rows, filters, sort_key, descending): validate_filters(filters) matched = [r for r in rows if passes(r, filters)] present = [r for r in matched if r.get(sort_key) is not None] missing = [r for r in matched if r.get(sort_key) is None] present.sort(key=lambda r: r[sort_key], reverse=descending) return present + missing def run_screen(rows, filters, sort_key, descending, page, page_size): page_size = max(PAGE_SIZE_MIN, min(page_size, PAGE_SIZE_MAX)) ordered = rank_all(rows, filters, sort_key, descending) start = page * page_size return ordered[start:start + page_size], len(ordered) The page-size bounds in the sketch mirror the 20 to 100 rows per page the tool offers. The total count comes from the full ordered set, so a UI can show real pagination.
What happens when an instrument has no score?
The score is a 0 to 100 rating built from five sector-relative pillars. ETFs and funds carry no score, and they are not in the screener grid at all.
That makes "no score" a real state you have to model, not an edge case. In this sketch, a missing value is never coerced to zero: it fails any filter on that field, and it sorts after rows that have a value. Those are design choices of the sketch, picked because a missing score is not a score of zero.
The score ranges are constants too, so filter and display can share one definition. The tool describes five ranges, from Strong Moonshot (80 and above) to Speculative (below 30). The sketch collapses the middle:
def score_band(score): if score is None: return 'unscored' if score >= STRONG_MOONSHOT_MIN: return 'strong' if score < SPECULATIVE_BELOW: return 'speculative' return 'middle' The point is the None branch coming first.
How do you make a screen shareable and exportable?
Keep filters, sort column and page size in the URL query string, so any screen can be bookmarked or shared. The tool works this way: filters, sorting and page size are reflected in the URL automatically. Parse the query into the same structure run_screen takes, so a copied link and the UI share one code path.
The tool also exports the filtered set to CSV, up to 500 rows. In the sketch, export gets its own function:
def export_rows(rows, filters, sort_key, descending): ordered = rank_all(rows, filters, sort_key, descending) return ordered[:EXPORT_ROW_CAP] What went wrong: a failure mode worth testing for
Here is the bug this design is built to prevent, shown as the naive version of the sketch. It is not a report of a past incident. The tempting shortcut is to build export by calling run_screen with page_size=EXPORT_ROW_CAP.
That looks reasonable and is wrong. run_screen clamps the page size to PAGE_SIZE_MAX, so the export would quietly return fewer rows than the cap promises. No error, no warning, just a truncated file. Very polite failure, very useless.
The fix is to stop routing export through the paginated function and call rank_all directly. The same family of bug applies to sorting: if the sort runs after the slice, you rank one page and call it a ranking. These tests use fixtures larger than any limit under test, so neither mistake can pass by accident:
def make_rows(count): return [{'moonshot_score': n, 'sector': 'tech'} for n in range(count)] def test_ranking_is_global_not_per_page(): rows = make_rows(SCORE_MAX) top = max(r['moonshot_score'] for r in rows) page, total = run_screen( rows, {}, 'moonshot_score', True, page=ZERO, page_size=PAGE_SIZE_MIN, ) assert page[ZERO]['moonshot_score'] == top assert total == len(rows) def test_export_is_not_limited_by_page_clamp(): rows = make_rows(EXPORT_ROW_CAP + PAGE_SIZE_MAX) out = export_rows(rows, {}, 'moonshot_score', True) assert len(out) == EXPORT_ROW_CAP A fixture smaller than the limit it tests proves nothing.
Lessons you can reuse
- Keep a closed filter schema and reject unknown keys.
- Filter, then rank the full set, then paginate.
- Treat missing values as missing, never as zero.
- Put every threshold in a named constant shared by API and UI.
- Give export its own path instead of reusing a clamped one.
- Make test fixtures larger than any limit under test.
- Document the single data source and its refresh cadence next to the schema, so nobody assumes freshness the data does not have.
One caveat: a single provider is a single point of failure for correctness. Consistency is worth that cost here, but it is a real limitation, and the provider's errors or delays flow straight into every row.
The project these notes come from:
A question for your own work: when you paginate a ranked list, where in your pipeline does the sort happen, and which test would fail if someone moved it after the slice?
This is educational content, not investment advice. Past performance does not guarantee future results.