Screener
The Screener filters a universe of 10,000+ US equities by their financial statements and shows the matches in a sortable table. Build a screen with no-code filter rows, or write a Python Screener Script for logic that filter rows cannot express. A saved screen can also pick the symbols for a bulk forward run or a backtest.
Screens run on fundamentals only. There is no live price or sector data, so the results table has no default Market Cap / Last / Net Change columns — the SIC industry description stands in for sector. Market cap, enterprise value and the period-end share price are available as fields in the Valuation group, so you can filter on them or add them as columns; they reflect each period's end, not today's quote. Crypto is excluded: it has no financial statements.
You must be signed in — the fundamentals data is downloaded from the QuantCraft servers.
Where in the UI
| Where | How to get there |
|---|---|
| Screener page | Navbar → Screener (directly after Trade) |
| Screener Script file | QuantCraft IDE → New File → kind Screener Script |
| Bulk forward target | AutoTrading or QuantCloud → new bulk run → target Screener |
| Backtest symbols | QuantCraft IDE → Run backtest → Symbol source → Screener |
| Agents | An agent's Test modal (Scheduler → Symbol source → Screener) and New Agent Bulk Run targets |
| Screener in its own window | Screener page → the Pop out button beside the page title |
The page has two cards: Filters (with the No-Code and Python Script tabs) and Results. Run sits in the filters footer beside Reset filters. The chevron on the Filters card (Collapse filters) folds it away to give the results more room.
The Pop out button beside the title opens the Screener in a separate window, so you can keep a screen on a second monitor while you work in the IDE or on a chart. The window is the same page — presets and Screener Scripts are shared with the main window — but each window keeps its own filters and its own results, and a screen runs in one window at a time. Close it with ← Return to main window; the app will not quit while a pop-out is still open.
When QuantCraft AI is driving a Screener window you'll see "The AI is using this window" and the controls are locked until it finishes.
Downloading fundamentals
The first time you use the Screener it downloads the whole fundamentals corpus to your machine. Until that cache has data, the Filters and Results cards are replaced by a gate with a Download Fundamentals button.
The download starts when the app opens, not when you open the Screener — by the time you get to the page it is usually already running or finished. Clicking Run during a download never starts a second one.
- A cold first run takes minutes. Every run after it takes seconds.
- The run banner shows the phase (
connecting → universe → download → convert → screen), a shard counter such asshard 14 of 32, a scanned/total count, and a Cancel button. A warm run only shows the screen phase. - The banner counts elapsed time up and shows no ETA — a run whose length swings by minutes cannot be predicted honestly. When it finishes you get a summary such as
12 matched in 3m 20s, followed by· Fundamentals updated <time>. If some symbols couldn't be fetched, a clickable N unavailable link opens the list of them. - An interrupted download resumes on the next run rather than starting over. Cancelling mid-download is safe: the unfinished part is simply re-fetched next time.
- A download keeps going even if you navigate away from the page.
How often data refreshes
| When | What downloads |
|---|---|
| Your very first screen | The whole universe |
| Any later screen the same UTC day | Nothing |
| The first screen the next day | Only symbols whose newest filing is more than 62 days old |
62 days is about a quarter, so a company is only re-checked once its next filing is plausibly out.
On the right of the run banner you will see a line like 10,384 symbols cached · 250 MB with a Clear cache button (hidden while a screen is running, disabled while the AI is using the window). Clearing asks for confirmation — Clear cache or Keep it — because it throws away a multi-minute download. The cache is regenerable data that an uninstall does not remove, which is why it is visible and reclaimable here.
After an app update that changes the cache format, the next screen re-downloads the whole universe once (this happened when Per Share, Valuation and Scores were added).
Building a screen (No-Code)
Each filter row reads left to right: Period → Field → Filter type → value input(s). Use + to add a row and − to remove one. The + button asks whether the new row is a Fundamentals row or an Industry row.
Rows combine with AND — a symbol must satisfy every enabled row. (A preset made by the AI or imported from a file can instead use OR — "Any filter can match"; Load Preset shows which.)
The default starter row (Quarterly / Total Revenue / % Change / 1 period back / min 0%) is valid as-is, so Run works immediately. Before a run starts, every enabled row is validated; if one is incomplete you get a toast and an inline error on that row, and the run does not start — a multi-minute scan should not begin on an unusable query.
A worked example: Quarterly / Total Revenue / % Change / periods back 3 / min 1 / max 3 finds companies whose revenue grew between 1% and 3% over the last three quarters.
Filter types
Every filter reads the latest value unless it says otherwise.
| Filter | Parameters | What it matches |
|---|---|---|
| Range | min, max | Latest value falls between min and max, inclusive. Leave one side blank for unbounded. |
| Above | value | Latest value is greater than value. |
| Below | value | Latest value is less than value. |
| Equals | value, tolerance | Latest value is within ± tolerance of value. |
| Has a value | — | The latest value exists. Useful with Financials Extended. |
| % Change | periods back, min %, max % | Percent change from n periods back to latest. Fails if the starting value is zero. |
| Absolute Change | periods back, min, max | Raw difference (latest − n back). |
| QoQ Change % | min %, max % | Percent change vs the previous quarter. Quarterly only — the period selector is locked. |
| YoY Change % | min %, max % | Percent change vs a year ago. The lag is automatic — 4 for quarterly, 1 for yearly. |
| CAGR % | periods back, min %, max % | Annualized growth rate. Both endpoints must be positive. |
| Average over N | periods back, min, max | Mean of the last N periods. Any missing value in the window fails the symbol. |
| Grew N in a row | periods back | Value increased every period across the window. |
| Fell N in a row | periods back | Value decreased every period across the window. |
| Above X for N | value, periods back | Every one of the last N periods is above value. |
| Below X for N | value, periods back | Every one of the last N periods is below value. |
| Top N by field | n | Ranking filter — keeps the top n symbols across everything scanned. |
| Percentile rank ≥ | min % | Ranking filter — keeps symbols at or above that percentile of everything scanned. |
The two ranking filters are decided across the whole population, not per symbol, so they are settled after every symbol has been scanned. A symbol with no value for the ranked field can never win one.
The largest periods back you can use is 40.
Choosing a field
The field picker is searchable and groups fields in this order:
Ratios → Valuation → Scores → Per Share → Income Statement → Balance Sheet → Cash Flow → Financials Extended → Earnings History
Ratios come first because they are the most screener-relevant and are already unit-normalized. Value inputs follow the field's unit — $, %, days, shares, per-share amounts, counts, or a bare number as appropriate. See the Fundamentals field reference for what each group contains.
- All groups support quarterly and yearly except Earnings History, which is quarterly only, and the CAGR fields (
revenueCagr3y,revenueCagr5y, …), which are yearly only and disappear from the quarterly picker. Changing the period clears the field if it does not exist for the new period. - "Periods back" counts rows inside the period you picked. Quarterly + 3 means three quarters; yearly + 3 means three fiscal years. There is no mixing of the two.
- Valuation multiples are trailing-twelve-month even on a quarterly row.
- Financials Extended fields vary by symbol. They only appear in the picker once the fundamentals cache has been downloaded, and are marked "varies by symbol". A filter on a field a given company does not report simply fails that company — pair it with Has a value when that matters.
- Future-dated period ends are ignored, so "latest" is never a period that has not happened yet.
- Not filterable: Company Info, Earnings Trend, Earnings Annual, TTM, the latest (as-of-today) valuation, metrics provenance, the Piotroski / Beneish score components, and text or yes/no keys such as
priceBasisorapproximate. These either carry no reporting period to compare, or aren't numbers.
Retired fields. Eight old Financials Extended fields were withdrawn because better versions now exist: ebitda, ebitdaMargin, enterpriseValue, priceToEarnings, priceToBook, priceToSales, evToEbitda, evToRevenue. A saved screen that used one loses that row (or column) when loaded — rebuild it from the Valuation group, which computes these on a trailing-twelve-month basis, so values differ from the old ones. (ratios.debtToEquity is simply renamed to ratios.debtToEquityRatio and still loads.)
Industry rows
An Industry row replaces the field/type/value controls with a multi-select of industries; a symbol passes if its industry is one of the ones you picked. At least one industry must be selected. The list is built from the data you have actually downloaded.
Python Script mode
Pick Python Script when a stack of AND-ed comparisons is not enough — multi-field arithmetic, conditional logic, or your own ranking score.
Create the file in the QuantCraft IDE via New File → Screener Script; it arrives with working boilerplate. Any .py file in your workspace that defines a top-level filter() also qualifies. The Screener's Python Script tab lists them (labelled by folder path, so files in different folders stay distinguishable) and previews the selected one. Your other workspace files are sent along with the run, so your script can import your own helper modules.
def filter(fundamentals, symbol):
q = fundamentals["incomeStatement"]["quarterly"]
if len(q) < 4:
return {"result": False}
latest, year_ago = q[-1]["totalRevenue"], q[-4]["totalRevenue"]
if latest is None or not year_ago:
return {"result": False}
growth = (latest - year_ago) / abs(year_ago)
return {"result": growth > 0.10, "value": growth}
def rank(value, symbol):
return min(max(value, 0), 1) * 100filter(fundamentals, symbol) is called once per ticker and must return a dict with a boolean "result". "value" is optional and is whatever you want to carry into ranking. A script that raises, returns a plain True, or returns a dict without a boolean "result" counts as no match for that ticker only — one bad ticker never aborts the run.
rank(value, symbol) is optional and runs only for tickers that matched. Return a real number — any scale works (0-1, 1-10, 0-100), and higher always means better. It fills the sortable Rank column, which appears in Python Script mode only. A rank() that fails does not unmatch the row; the row keeps its match and its rank shows —, sorting last.
The symbol argument is optional on both functions — the older single-argument filter(fundamentals) and rank(value) still work.
The fundamentals argument
{
"symbol": "AAPL",
"entityName": "Apple Inc.",
"sicDescription": "ELECTRONIC COMPUTERS",
"balanceSheet": {"quarterly": [...], "yearly": [...]},
"incomeStatement": {"quarterly": [...], "yearly": [...]},
"cashFlow": {"quarterly": [...], "yearly": [...]},
"ratios": {"quarterly": [...], "yearly": [...]},
"perShare": {"quarterly": [...], "yearly": [...]},
"valuation": {"quarterly": [...], "yearly": [...]},
"scores": {"quarterly": [...], "yearly": [...]},
"financialsExtended": {"quarterly": [...], "yearly": [...]},
"earnings": {"history": [...]},
}Nothing as-of-upload reaches a script: ttm, valuation.latest, metricsInputs and metricsHealth carry no period, and a screen compares periods across symbols. valuation multiples are trailing-twelve-month even on a quarterly row, and a None multiple beside a signed yield means a non-positive denominator, not missing data.
- Values are numbers or
None— never strings like"1234.00". Keys that aren't numbers are left out entirely:beforeAfterMarket,reportDateandcurrencyinearnings.history, thepiotroskiComponents/beneishComponentsbreakdowns inscores, and text flags likepriceBasis/approximateinvaluation. - Rows are oldest first, so
rows[-1]is the latest period. Each row carries adate(its period end). - You get at most the 12 most recent quarterly and 6 most recent yearly rows, which caps how far back a script can look.
Anything your script print()s is captured and shown by the Output logs button next to Run — on failed runs too, so you can see what printed just before a crash. The buffer holds 200,000 characters and drops the oldest output past that. A no-code run does not clear the last script run's logs.
Run is blocked with a toast if no script is selected or the selected file has no filter().
Results
Every row shows a star and chart button, Rank (Python Script mode only), Symbol, Company, Industry and As of, plus a column per filter row showing the value that matched (No-Code mode) and any extra fields you add.
"As of" is the most recent period end used for that symbol — the fiscal period your filters were actually evaluated against. It is never a future period.
| Control | What it does |
|---|---|
| Column header | Sorts the whole table, not just the page. Empty values always sort last, in both directions. |
| Columns picker | Adds or reorders result columns. Never re-runs the screen — a genuinely new field fetches just that column for the rows on screen. |
| Search box | Filters the rows you already have. |
| Tradable only | Hides symbols that are not in your broker account's tradable symbol lists. |
| Star | Adds the symbol to a watchlist. Starred symbols appear as chips above the table and persist between sessions. |
| Symbol (click) | Opens a per-symbol fundamentals detail modal, including a Factor Exposure tab. |
| Chart button | Opens a Trade chart for that symbol in a pop-out window (tradable symbols only). |
Matches appear all at once when the run finishes, so the table stays empty while a screen is in progress — watch the banner for progress.
Results are not saved. Reloading the app clears the table on purpose, since a stale result is worse than none. Your filters, columns, starred symbols, active tab, and selected script are remembered.
Presets
A preset is a named snapshot of your filter rows, their join, and your result columns.
| Button | What it does |
|---|---|
| Save Preset | Names and saves the current screen. Disabled until you have changed something from the default — picking columns alone does not count. |
| Load Preset | Browses saved presets, showing each one's description, whether its filters are AND-ed ("All filters must match") or OR-ed ("Any filter can match"), and a plain-English summary of its filters. |
| Update {name} | Replaces the preset you loaded, instead of creating a new one. Save Preset changes into this once you edit a loaded preset. |
| Reset filters | Clears the loaded-preset link, so the button goes back to Save Preset. |
Inside Load Preset you can also:
- Delete preset — removes it after a confirmation.
- Export preset — saves it as a JSON file you can share or back up.
- Import preset — loads a preset from such a JSON file.
Using a screen to pick symbols
Bulk forward runs
In the New Run modal's Targets tab, pick the Screener scope. The run trades whatever a saved preset or Screener Script matches, re-screening every Refresh interval (days) while it is live. Include tickers with open positions keeps a holding in the rotation after it stops matching. Screener targets are equity-only. With Use fundamentals on, every symbol in the current screen gets its fundamentals automatically, and the set follows each re-screen.
On QuantCloud the runner keeps its own copy of the fundamentals the screen needs. The first screener run on a new runner downloads it all, which takes a while; after that it only refreshes once a day. The runner uses your sign-in to download, and it can't renew that sign-in on its own, so on a run lasting many days, re-screens keep using the data it already has once the sign-in expires.
| Field | Notes |
|---|---|
| Screener source | Screener preset or Python script. |
| Preset / Screener script | Which one to run. |
| Sort column / Direction | How preset matches are ranked before the limit applies. |
| Rank direction | For scripts — which end of the script's rank() score to take first. |
| Limit | How many symbols to trade. 0 takes every match. |
| Refresh interval (days) | How often the screen re-runs while the run is live. Minimum and default is 1 day. |
| Refresh time (ET) | Time of day each re-screen happens. Defaults to 16:30 (half an hour after the US market closes). |
| Include tickers with open positions | Adds symbols you already hold a position in even when they no longer match, so a live run never silently drops a holding. |
Why 16:30 ET is the default. Picking up a new universe restarts the run, so a re-screen that lands at 11:00 would interrupt live trading. Anchoring it to a time after the close means the refresh happens while nothing is trading and the new symbol list is ready before the next session opens.
- The time is always market time (ET), whatever timezone you are in. The helper text under the field shows your own local equivalent, so
16:30 ET · 21:30 your timeis the same instant, not two settings. - Changing it is allowed at any time. If you pick a time inside the 09:30–16:00 ET session you get a warning under the field explaining that the restart will interrupt trading, but the run still starts — the choice is yours.
- The interval and the time work together.
1day at16:30re-screens every day after the close;3days at16:30re-screens after the close every third day. - Leaving the field empty or entering something that is not
HH:MMblocks the run with an inline error.
See AutoTrading and QuantCloud for the full New Run modal.
Backtests
In the Run backtest modal's Symbol & data tab, set Symbol source to Screener. Instead of trading a fixed list, the backtest re-runs your screen as it goes and trades whatever matched at that point in the simulation.
| Setting | What it does |
|---|---|
| Screener source | Screener preset or Python script. |
| Sort column / Direction | How preset matches are ranked before the limit is applied. |
| Rank direction | For scripts — which end of your rank() score to take first. |
| Limit | How many symbols to trade per rebalance. 0 takes every match. |
| Refresh interval (days) | How often the screen re-runs, in simulated calendar days. Scripts require 7 days or more. |
| Keep tickers with an open position | Keeps a symbol tradable after it stops matching, so an open position can still be managed. |
Fundamentals follow the screen. With Uses fundamentals on, every screened symbol gets its fundamentals in on_bar / on_tick automatically — no need to pick them. They are read from the Screener's cache, so a symbol the Screener already downloaded costs no extra download. Always pass symbol=symbol when reading them — see Fundamentals.
"Keep tickers with an open position" affects trading, not valuation. With it on, a symbol you are still holding keeps receiving bars after it drops out of the screen, so your strategy can exit it. With it off, the symbol stops receiving callbacks and the position simply stays open until the backtest ends. Either way the position is always marked to the symbol's real price, so your equity curve and metrics are correct.
on_init re-fires at every rebalance. When a screener symbol source is active, the engine calls on_init(symbols=<ticker list>) again each time the screen re-runs. This lets your strategy respond to a new universe without restarting the simulation. Key points:
- At run start,
on_initfires twice back-to-back — the standard setup call (withsymbols=None) followed immediately by the initial screen result. - Each re-fire sees whatever module-level state the previous call left. State is not reset between rebalances.
- Strategies that declare
def on_init():(no parameter) are unaffected — optional arguments are matched by name, so the missingsymbolsis simply ignored.
Calendar data for a screened universe
Include calendar data works with a screener symbol source. Calendars load once the screen has resolved the full ticker list for a rebalance — there is no per-tick lookup lag. Points to know:
- Symbols matched by the screen but with no price bars (outside the date range or delisted before the run starts) cost no calendar request.
- A wide screen will often match symbols that have no calendar on the server — typically smaller or less-covered tickers. The run log reports the count rather than failing the run.
- Calendars are refreshed at each rebalance to cover newly added tickers.
Results appear in a Screener Output tab beside Logs, showing every rebalance as a collapsible date. Click + to expand a date and see the symbols traded for that window, or use Expand all. Because the screen is resolved before the first bar, the whole schedule is there from the start.
Requirements to know: a screener-driven backtest needs explicit start and end dates; additional timeframes are not supported (so get_bar returns None — use each ticker's own bar.close[i] offsets for history); and Debug Test can't use a screener symbol source — switch to Selected symbols to debug. See Backtests.
Point-in-time and survivorship
Each rebalance only sees reports that had actually been filed by that date. A screen at 5 May 2021 will not see a quarter that ended in April but was not published until June. The exception is a period with no recorded filing date, which is admitted from its period end — rare, but it means a small amount of look-ahead is possible there. That is what makes the result honest, and it is also why a backtest's picks will not match what you get by screening the same date against today's data.
Two things still reflect today, not the simulated date:
- On a connected broker feed, matches are filtered to symbols tradable on your broker right now, so a company delisted since is dropped even from a rebalance where it was perfectly tradable. A QuantCraft (B2) feed skips this check.
- The cached universe only holds companies the fundamentals corpus still carries, so a long-delisted company will not appear at all.
Point-in-time accuracy applies to the values a screen reads, not to which companies exist in it. Treat long-horizon results with that in mind.
Interpreting results
Not having enough history fails a symbol — it never quietly passes. Asking for "Grew 8 in a row" on a company with five quarters of data excludes it. Excluding rather than passing means a result set never over-reports.
Other cases where a symbol is excluded rather than matched:
- % Change cannot compute from a starting value of zero.
- CAGR % needs both endpoints to be positive.
- Average over N fails if any value in the window is missing — nulls are not skipped.
- A field the company does not report fails that company, which matters most for Financials Extended.
- Ranking filters reject symbols that have no value for the field being ranked.
One ranking quirk: tied values do not share a percentile rank, so two identical values can land on opposite sides of a Percentile rank ≥ cutoff.
If the backend cannot be reached you get a clear error rather than a slow silent fallback, and a run that goes quiet for 90 seconds is reported as an error instead of hanging.
See also
- AutoTrading — bulk forward runs, including the Screener target scope
- QuantCloud — cloud runners with Screener-driven bulk jobs
- Backtests — using a screen as the backtest symbol source
- Fundamentals — reading fundamentals inside strategy callbacks, and the field reference
- QuantCraft AI — letting the AI build and run screens for you
- Fama-French factors — the factor data behind the detail modal's Factor Exposure tab
