Project: Build a Daily Stock Screener
This project produces two formulas that belong together: a screener that returns tonight’s candidate list, and a stage audit that tells you where the rest of the universe went. Most published screeners ship only the first. The second is what makes the first trustworthy, and it is the part you will use most.
Budget an hour. Twenty minutes of it is running the audit and arguing with your own thresholds, which is the actual work.
Objective
Section titled “Objective”Build a daily end-of-day screener that:
- runs over a universe you define, on a broad equity database with daily bars;
- rejects, in a documented order, what you could not trade and what you could not evaluate;
- returns a short, ranked list of candidates with enough context per row that you can decide which charts to open;
- can be audited stage by stage, so that “no candidates today” is a statement with evidence behind it rather than a shrug.
Pipeline design
Section titled “Pipeline design”The six stages, in execution order
- 0 — UniverseExclude composite tickers; require enough history for every indicator to be defined
- 1 — PriceClose > 5. Keeps percentage measures away from tick quantisation
- 2 — Liquidity50-bar average turnover above a floor. Currency, not share count
- 3 — TrendAbove a rising 200-bar average. A state, not an event
- 4 — Momentum60-bar rate of change positive. A different horizon from stage 3
- 5 — VolatilityATR between 1% and 8% of price. A band, with both ends justified
- 6 — SetupAt a 10-bar closing high, and up on the day. The only stage about today
Three design decisions in that pipeline are worth stating explicitly, because they are the ones a reader would reasonably want to argue with.
Stage 0 exists because Null is silent. A symbol listed four months ago has no
two-hundred-bar average. In AFL that produces Null, and Null > 5 is not true, so the
symbol disappears from the results with no message. Testing NOT IsNull( Trend ) explicitly
converts a silent disappearance into a countable category. The audit formula then tells you
that 340 symbols were dropped for lack of history — which might be fine, or might mean your
database import went wrong.
Stage 3 requires a rising average, not merely a price above it. Close > Trend alone is
satisfied on the first bounce of a long decline. Adding Trend > Ref( Trend, -20 ) requires
the average itself to have been going up for a month. This is stricter, it is later, and it
is a trade you should make consciously.
Stage 6 is a state expressed as a comparison, not a Cross(). Close >= HHV( Close, 10 )
is true on any bar that closes at or above the highest close of the last ten bars, which on a
strongly trending instrument is several bars in a row. If you want strictly the first such
bar, that is a different screen and it will return roughly a fifth as many rows.
The screener
Section titled “The screener”Complete runnable AFL
// daily-screener.afl// Part 12 - Project: Build a Daily Stock Screener//// THE PIPELINE, in the order it executes://// 0. Universe - drop artificial composite tickers, and any symbol whose// history is too short for the indicators to be defined// 1. Price - a floor below which percentage statistics are dominated// by the tick size// 2. Liquidity - average turnover, in currency, not share count// 3. Trend - a state that has held for weeks, measured on the close// 4. Momentum - measured over a different horizon from the trend test// 5. Volatility - a band, not a ceiling: too quiet is also a rejection// 6. Setup - the one condition that is about today rather than context//// WHAT THIS IS NOT. A screen is a way of deciding which charts are worth your// attention this evening. It contains no evidence about what happens after a// symbol appears on it. Part 12's reality check measures that separately, and// Parts 27 to 35 are where a candidate list becomes a testable system.//// ASSUMPTIONS, so they can be argued with:// - Daily bars. Nothing here inspects Interval(), so weekly data silently// changes the meaning of every period.// - Volume is share volume; Close * Volume is therefore turnover in the// instrument's quotation currency. Check this against your data provider// before trusting the liquidity stage.// - Prices need not be adjusted for splits and dividends for the trend and// setup stages to be meaningful, but an unadjusted series will produce// false setups on the split bar. Data quality is Part 2's subject.// - The thresholds are starting points calibrated for a large liquid equity// market. Re-derive them from your own universe; see the stage audit.//// HOW TO RUN IT: Analysis window, Apply to = Filter (your universe watch// list), Range = All quotations, then press EXPLORE. Pressing Scan produces// nothing, because this formula assigns Filter and defines no Buy.
// ---- Stage 0: universe hygiene ------------------------------------------// Composite tickers created by AddToComposite() conventionally start with "~".// They are not instruments and must never reach a candidate list.Exclude = StrLeft( Name(), 1 ) == "~";
// ---- Thresholds ----------------------------------------------------------MinPrice = 5;LiquidityPeriod = 50;MinTurnover = 1000000;TrendPeriod = 200;TrendSlopeBars = 20;MomentumPeriod = 60;MinMomentum = 0;AtrPeriod = 20;MinAtrPct = 1;MaxAtrPct = 8;SetupPeriod = 10;StopAtrMultiple = 2;
// ---- Derived series ------------------------------------------------------Turnover = Close * Volume;AvgTurnover = MA( Turnover, LiquidityPeriod );Trend = MA( Close, TrendPeriod );Momentum = ROC( Close, MomentumPeriod );AtrValue = ATR( AtrPeriod );AtrPct = 100 * AtrValue / Close;DistancePct = 100 * ( Close - Trend ) / Trend;
// A symbol with less history than the longest look-back produces Null here.// Testing for that explicitly is the difference between a screen that reports// "no candidates" and a screen that reports "these 340 symbols could not be// evaluated" - the second one is debuggable.HasHistory = NOT IsNull( Trend ) AND NOT IsNull( Momentum ) AND NOT IsNull( AvgTurnover ) AND NOT IsNull( AtrValue );
// ---- The six stages ------------------------------------------------------StagePrice = Close > MinPrice;StageLiquidity = AvgTurnover > MinTurnover;StageTrend = Close > Trend AND Trend > Ref( Trend, -TrendSlopeBars );StageMomentum = Momentum > MinMomentum;StageVolatility = AtrPct > MinAtrPct AND AtrPct < MaxAtrPct;StageSetup = Close >= HHV( Close, SetupPeriod ) AND Close > Ref( Close, -1 );
Candidate = IsTrue( HasHistory AND StagePrice AND StageLiquidity AND StageTrend AND StageMomentum AND StageVolatility AND StageSetup );
// ---- Output --------------------------------------------------------------// Status("lastbarinrange") keeps the whole history available to the averages// while reducing the report to one row per surviving symbol.Filter = Status( "lastbarinrange" ) AND Candidate;
// A crude, explicit risk yardstick: how far below today's close a stop placed// two average ranges away would sit. It is here so that the list cannot be// read as "these are all equally good"; a candidate whose stop is 14% away is// a different proposition from one whose stop is 3% away.StopDistancePct = 100 * StopAtrMultiple * AtrValue / Close;
TurnoverBar = PercentRank( AvgTurnover, 100 );
AddTextColumn( FullName(), "Name", 34 ); // column 3AddColumn( Close, "Close", 1.2 ); // column 4AddColumn( Momentum, "60-bar ROC %", 1.1, IIf( Momentum > 10, colorDarkGreen, colorDefault ) ); // column 5AddColumn( DistancePct, "% from MA200", 1.1 ); // column 6AddColumn( AtrPct, "ATR %", 1.2 ); // column 7AddColumn( StopDistancePct, "Stop dist %", 1.1, IIf( StopDistancePct > 10, colorDarkRed, colorDefault ) );// column 8AddColumn( AvgTurnover, "Turnover 50d", 1.0, colorDefault, colorLightBlue, -1, TurnoverBar ); // column 9AddColumn( DateTime(), "As of", formatDateTimeISO ); // column 10
SetSortColumns( -5 ); // strongest 60-bar momentum firstAddRankColumn(); // column 11: survives a manual re-sort
AddSummaryRows( 2 + 16, 1.2, 5, 6, 7, 8 );How it works
Section titled “How it works”The header comment is not decoration. It records the pipeline, the assumptions, and the explicit non-claim. Six months from now the thresholds will look arbitrary and you will want to know what you were thinking; a formula whose assumptions are only in your head is a formula you cannot revise safely.
The universe stage uses the predefined variable Exclude. Assigning it a true value
removes the current symbol from the run entirely — a per-symbol kill switch that runs inside
the formula, complementary to the Filter Settings window rather than a replacement for it.
Here it removes artificial composite tickers by name.
The threshold block puts every free number in one place, at the top, with a comment saying what it means. No magic numbers are buried in expressions. This is what makes the stage audit possible: the two formulas can be kept in step by eye.
The derived series block computes each measure once. AtrPct normalises the average true
range by price so that instruments of different prices are comparable; DistancePct does the
same for the distance to the trend line.
HasHistory is the explicit Null guard. Note that it tests the indicators, not the bar
count — that is the right test, because what matters is whether the values you are about to
compare exist.
The six stage variables each hold one idea, named. Candidate is their conjunction,
wrapped in IsTrue(), which returns 1 when a value is neither empty nor zero. That wrapper
converts any surviving Null into a definite 0 rather than letting it propagate through the
AND chain, where a single unknown makes the whole conjunction unknown.
The Filter line combines Status("lastbarinrange") — one row per symbol, at the end of
the range — with Candidate. The wide Range keeps the full history available to the
averages; the Status() term reduces the report to the current state.
Column design
Section titled “Column design”Columns are chosen by asking what decision each one supports.
| Column | The question it answers |
|---|---|
| Name | Which company is this? Tickers are not memorable across a whole market. |
| Close | What does one unit cost? Feeds the position-size arithmetic you do next. |
| 60-bar ROC % | How strong is this, on the horizon the screen selected for? Coloured when strong. |
| % from MA200 | How extended is it? A candidate 45% above its trend line is a different proposition from one 4% above. |
| ATR % | How much does it move in a day? Determines whether your intended stop is realistic. |
| Stop dist % | How far away would a two-ATR stop sit, as a percentage? |
| Turnover 50d | Could you trade it at your size? With an in-cell bar for fast comparison. |
| As of | Which bar is this? ISO format, so it survives export and travel. |
| Rank | Where did this row sit on the screen’s own ordering, after you re-sort? |
The “Stop dist %” column is the one that does the most work and is most often missing from published screeners. Without it, a candidate list reads as though every row is an equally good idea. With it, the row whose stop sits 14% away is visibly a different kind of proposition from the one whose stop sits 3% away — before you have opened a single chart.
Expected result
Section titled “Expected result”The stage audit
Section titled “The stage audit”Complete runnable AFL
// screener-stage-audit.afl// Part 12 - Project: Build a Daily Stock Screener (validation formula)//// Answers one question the screener itself cannot: where does the universe// go? Each column is the CUMULATIVE conjunction of the stages up to that// point, so the TOTAL summary row reads as a funnel - how many symbols were// examined, how many survived stage 1, how many survived stages 1 and 2, and// so on. Run it before you trust a candidate list, and again whenever you// change a threshold.//// Read the funnel like this:// - a stage that removes almost nothing is not doing any work; either the// threshold is too loose or the stage restates an earlier one// - a stage that removes almost everything is where an empty candidate list// comes from, and is the only stage worth arguing about// - "Examined" far below your universe size means the run never saw the// symbols you thought it did: check Apply to, and check "Not enough data"//// Assumptions and thresholds are copied deliberately from daily-screener.afl.// If you change one there, change it here; a stage audit that measures a// different pipeline from the one you run is worse than no audit.
Exclude = StrLeft( Name(), 1 ) == "~";
MinPrice = 5;LiquidityPeriod = 50;MinTurnover = 1000000;TrendPeriod = 200;TrendSlopeBars = 20;MomentumPeriod = 60;MinMomentum = 0;AtrPeriod = 20;MinAtrPct = 1;MaxAtrPct = 8;SetupPeriod = 10;
AvgTurnover = MA( Close * Volume, LiquidityPeriod );Trend = MA( Close, TrendPeriod );Momentum = ROC( Close, MomentumPeriod );AtrValue = ATR( AtrPeriod );AtrPct = 100 * AtrValue / Close;
HasHistory = NOT IsNull( Trend ) AND NOT IsNull( Momentum ) AND NOT IsNull( AvgTurnover ) AND NOT IsNull( AtrValue );
// IsTrue() converts "unknown" to 0 rather than letting Null spread through the// conjunction. Without it, a single unwarmed indicator turns every downstream// column into Null and the funnel reads as though the stage rejected the// symbol, which is a different finding entirely.Pass0 = IsTrue( HasHistory );Pass1 = IsTrue( Pass0 AND Close > MinPrice );Pass2 = IsTrue( Pass1 AND AvgTurnover > MinTurnover );Pass3 = IsTrue( Pass2 AND Close > Trend AND Trend > Ref( Trend, -TrendSlopeBars ) );Pass4 = IsTrue( Pass3 AND Momentum > MinMomentum );Pass5 = IsTrue( Pass4 AND AtrPct > MinAtrPct AND AtrPct < MaxAtrPct );Pass6 = IsTrue( Pass5 AND Close >= HHV( Close, SetupPeriod ) AND Close > Ref( Close, -1 ) );
Filter = Status( "lastbarinrange" );
AddColumn( Pass0, "0 has history", 1.0 ); // column 3AddColumn( Pass1, "1 price", 1.0 ); // column 4AddColumn( Pass2, "2 liquidity", 1.0 ); // column 5AddColumn( Pass3, "3 trend", 1.0 ); // column 6AddColumn( Pass4, "4 momentum", 1.0 ); // column 7AddColumn( Pass5, "5 volatility", 1.0 ); // column 8AddColumn( Pass6, "6 setup", 1.0 ); // column 9
// TOTAL gives the survivor count for each stage; COUNT gives the number of// symbols the run actually examined. Both rows appear at the TOP of the list.AddSummaryRows( 1 + 16, 1.0, 3, 4, 5, 6, 7, 8, 9 );How it works
Section titled “How it works”Each column is the cumulative conjunction of the stages up to that point, so the TOTAL summary row reads down the page as a funnel: how many symbols had enough history, how many of those cleared the price floor, how many of those were liquid, and so on. The COUNT row gives the number of symbols the run actually examined.
IsTrue() wraps every stage for the same reason as in the screener, and here the consequence
is sharper: without it, one unwarmed indicator turns every downstream column into Null, and
the funnel then reads as though the stage rejected the symbol. Rejected and not-evaluated are
different findings and must not be conflated in a diagnostic tool.
Validating each stage
Section titled “Validating each stage”Run the audit before you trust a single candidate list, and again after every threshold change. Read it in this order.
Check the COUNT first. Does it match the number of symbols you believe are in your
universe? If it is much lower, Apply to is not what you think it is, or Exclude is
removing more than you intended. If it is much higher, you are including composites and
indexes. Nothing further in the audit means anything until this number is right.
Check stage 0. How many symbols lack the history to be evaluated at all? On a mature database this should be a small minority. If it is half your universe, either your database was recently rebuilt, or a data import failed partway, or your longest lookback is longer than your data.
Check each stage’s yield. Divide each stage’s TOTAL by the previous one’s. You are looking for two pathologies:
- A stage that removes almost nothing is not a filter. Either the threshold is inert, or the stage restates a previous one. Both are worth fixing: the first because it is a false sense of rigour, the second because it makes the screen look like it has more independent evidence behind it than it does.
- A stage that removes almost everything is where an empty candidate list comes from, and is the only stage worth arguing about. Before changing it, check whether it is supposed to be that selective. Stage 6 removing 95% of survivors is correct; stage 2 removing 95% of the universe usually means the threshold belongs to a different market.
Check the ratio between stage 5 and stage 6. Stage 6 is the only stage about today, so it is the one whose yield varies from day to day. Run the audit on three different days and note how much stage 6’s output moves while stages 0 to 5 barely change. That variability is the honest picture of what a daily screen is: a mostly stable universe with a small, weather- dependent subset surfacing each evening.
Why the order of the stages matters here
Section titled “Why the order of the stages matters here”The six stages are all absolute tests against fixed numbers, so reordering them would not change the surviving set. It would change three other things, and in this project each of them is a real cost:
- The audit becomes useless. The funnel only reads as a funnel because each column is the cumulative conjunction in the order the stages are written. Move liquidity to the end and every illiquid symbol is reported as failing the trend test — a true statement that tells you nothing.
- The numerical guards stop guarding.
AtrPctdivides byClose, and stage 1 is what keeps that division away from the prices where a single tick is several per cent. Nothing errors if you reorder; the volatility filter simply starts measuring tick size for part of the universe. - The cost changes if you ever split the pipeline. The next lesson runs a scan pass and an exploration pass from one file. As soon as work is divided across passes, what each pass has to examine depends on what the previous one left.
And the moment you add a relative stage, order stops being a matter of taste. Suppose you extend the screener with “keep only the top twenty by momentum”. Applied before the liquidity stage, the top twenty across the whole database will be dominated by small, thinly traded instruments, and after liquidity you may have two candidates. Applied after, you get twenty tradeable ones. Percentiles, z-scores, top-N selections and any threshold derived from the surviving group all behave this way. Absolute filters first, relative selection last.
Common errors
Section titled “Common errors”The audit and the screener disagree. They share thresholds by copy, not by reference, so
they drift. Keep the two files open side by side when you change a number. Part 11’s
#include machinery is the durable fix, and moving the shared block into an include file is
the natural first extension of this project.
Composite tickers in the candidate list. Exclude was not assigned, or your composites do
not use the ~ prefix. Check the symbol tree.
Rows for symbols with obviously wrong prices. A single bad bar can produce a spurious ten-bar high. Part 2’s data-quality checks belong upstream of any screen, and a screener is an excellent detector of import errors precisely because it surfaces the extremes.
The Rank column renumbers when you click a header. AddRankColumn() ran before
SetSortColumns().
Extensions
Section titled “Extensions”Attempt these in order; each one is a genuine improvement rather than a decoration.
- Share the thresholds. Move the threshold block into an include file and
#includeit from both formulas, so the audit cannot silently measure a different pipeline. Part 11 covers the mechanics and the load-order traps. - Add a “days in setup” column.
BarsSince( Close < HHV( Close, 10 ) )tells you whether this is the first bar at a ten-bar high or the ninth. Those are different candidates and the current table cannot distinguish them. - Add a sector column and a sector count.
AddTextColumn( SectorID( 1 ), "Sector", 24 );costs one line and immediately reveals when tonight’s twelve candidates are eleven from one sector — which they very often are, and which changes what the list means entirely. - Make the setup selectable. Replace stage 6 with one of three alternatives chosen by a constant at the top, and run the audit for each. Comparing how many candidates each setup produces on the same evening is a more useful piece of information than any single list.
- Record the run. The next lesson adds a journal that appends the date, the settings and the thresholds to a CSV file every time you run the screen. That is what turns a nightly habit into something you can look back at.
What changed in your understanding
Section titled “What changed in your understanding”You now have a screener and, more importantly, an instrument for interrogating it. The
difference between the two matters: the screener answers “which symbols tonight?”, and the
audit answers “and why only those?” — a question no candidate list can answer about itself.
You have also seen why a formula’s assumptions belong in the file, why Null needs an
explicit decision rather than a default, and why the ordering of stages is a matter of
diagnosis and safety when the filters are absolute, and a matter of correctness the moment
one of them is relative.
Check your understanding
Sources for this lesson
6 verified · checked 2026-08-31
- 01AmiBroker User's Guide — How to create your own explorationamibroker.com/guide/h_exploration.html2026-08-31
- 02AFL Function Reference — AddColumnamibroker.com/guide/afl/addcolumn.html2026-08-31
- 03AFL Function Reference — AddSummaryRowsamibroker.com/guide/afl/addsummaryrows.html2026-08-31
- 04AFL Function Reference — Statusamibroker.com/guide/afl/status.html2026-08-31
- 05AFL Function Reference — IsTrueamibroker.com/guide/afl/istrue.html2026-08-31
- 06AFL Function Reference — ATRamibroker.com/guide/afl/atr.html2026-08-31
Every technical claim on this page was checked against the official AmiBroker documentation on the date shown. Where the course disagrees with folklore, the source is how you can tell which one to trust.