Project: Market Relative Strength Ranking Tool
This is the tool the previous four lessons were building towards: an Exploration that scores every eligible symbol in a universe against a benchmark over three lookback windows, ranks those scores across the whole universe bar by bar, and reports each symbol’s position together with the evidence you need to decide whether to believe it.
Set aside an hour. Roughly twenty minutes of that is running and checking rather than typing, and the checking is the part that makes the tool worth having.
Objective
Section titled “Objective”Produce a table, for one bar, with one row per eligible symbol, ordered by rank, showing:
- the symbol’s rank in the universe, where rank 1 is the strongest;
- the blended excess return that produced that rank;
- the rank expressed as a percentage of the field, so the number survives a change of universe size;
- the liquidity and price figures the eligibility decision was made on;
- for excluded symbols, the reason they were excluded;
- two self-check numbers that make a broken ranking visible instead of plausible.
And produce it without leaving anything behind in memory that could contaminate the next run.
Design decisions
Section titled “Design decisions”Every one of these is a choice, and a different choice would give a different tool. They are set out here so you can change them deliberately rather than by accident.
The score: a blended excess return
Section titled “The score: a blended excess return”The score is the weighted average of the symbol’s excess return over the benchmark across three windows — 63, 126 and 252 bars by default, roughly a quarter, a half and a year of daily trading. Three windows rather than one, because Part 13’s first lesson showed that a single window is a strong opinion disguised as a measurement, and blending at least makes the opinion explicit and adjustable.
Excess over a benchmark rather than raw return, because a ranking of raw returns in a rising market mostly ranks how much of the market’s move each symbol captured. Subtracting the benchmark’s return over the same bars leaves what is specific to the symbol. Both are defensible; the weights and the benchmark are parameters so you can compare them.
Eligibility before ranking
Section titled “Eligibility before ranking”Three absolute tests decide who is in the comparison at all: a minimum close price, a
minimum median turnover over 50 bars, and enough history for the longest lookback plus
the turnover window to be defined. Symbols that fail get a Null score on the bars where
they fail, so they take no position in the ranking.
The order matters. Ranking first and filtering afterwards would give you the top twenty of everything and then remove most of them, leaving a ragged list of whatever survived. Filtering first gives a top twenty of things you could actually act on.
Median turnover rather than average turnover, because one frantic day on fifty times normal volume should not qualify a stock that is untradeable on the other 49 days.
Where the universe comes from
Section titled “Where the universe comes from”From the Analysis window, through GetOption( "ApplyTo" ) and
GetOption( "FilterIncludeWatchlist" ), not from a list typed into the formula. This is
the multi-threading chapter’s own recommendation, and it removes an entire class of bug:
the list you rank and the list you report on cannot disagree, because there is only one
list.
The formula refuses to run with Apply To set to “current symbol” and says why. A ranking of one symbol is not a ranking.
Naming and cleanup
Section titled “Naming and cleanup”One prefix family, RsRank..., long enough to be unmistakably this tool’s. Input scores
go under RsRankScore, ranks are generated with the output prefix RsRankOrder — so the
generated names begin RsRankOrderRsRankScore, which does not begin with RsRankScore
and therefore cannot be fed back into a second ranking call. Everything is removed at the
start of pass one, with the wildcard, and nothing is written with persistent set.
What one run of the tool does
- GuardRead Apply To; refuse a single-symbol run
- RemoveWipe this tool’s own static variables, wildcard included
- Benchmark onceSetForeign, read closes, restore
- Score every symbolEligibility, three lookbacks, blend, store
- Rank onceStaticVarGenerateRanks, normal mode
- Self-checkCount what was scored; record the highest rank issued
- Report per symbolRead own score and rank; build the table
The complete formula
Section titled “The complete formula”Complete runnable AFL
// rs-ranking-tool.afl// Part 13 - Project: Market Relative Strength Ranking Tool//// A cross-sectional relative strength ranking with an Exploration front end.// It scores every eligible symbol in the chosen universe against a benchmark// over three lookback windows, ranks the scores across the universe bar by// bar, and reports the rank together with the evidence needed to check it.//// WHAT THIS IS NOT: it is not a trading system. There is no entry, no exit, no// position sizing, no cost model and no risk control anywhere in this file.// A rank is a description of the cross-section, nothing more.//// How to run it: Analysis window, Apply to = Filter (a watch list) or All,// Range = 1 recent bar, then Explore. Set the parameters from the Analysis// window's Parameters button.//// Assumptions declared up front:// - Daily bars, one currency, prices adjusted consistently for splits.// Unadjusted prices make every momentum score wrong on split days.// - The benchmark must exist in this database. If it does not, the formula// stops with a message rather than silently ranking absolute returns.// - Everything inside one AFL execution is aligned to the bar grid of the// symbol that execution is running on. Pass 1 runs on the first symbol of// the universe, so the whole ranking inherits that symbol's calendar.// Use a full-history reference symbol, or turn on "Pad and align all data// to reference symbol" in Analysis settings, before trusting long history.// - StaticVarSet stores only the bars currently in use, so the Analysis// range and QuickAFL decide how much history the ranking covers.// - The ranking function is called ONCE per run, inside the// Status("stocknum") == 0 block, as the documentation requires.
// ------------------------------------------------------------------- naming// One long, distinctive prefix family. Static variable names are shared by// every formula in the running AmiBroker, and the ranking function matches// input names by "starts with", so a short prefix is a correctness bug.InputPrefix = "RsRankScore";OutputPrefix = "RsRankOrder";ScoredName = "RsRankScoredCount";IssuedName = "RsRankHighestIssued";BenchName = "RsRankBenchmarkOk";
// --------------------------------------------------------------- parametersBenchSymbol = ParamStr( "Benchmark symbol", "^GSPC" );
Look1 = Param( "Lookback 1 (bars)", 63, 10, 250, 1 );Look2 = Param( "Lookback 2 (bars)", 126, 20, 400, 1 );Look3 = Param( "Lookback 3 (bars)", 252, 40, 750, 1 );
Weight1 = Param( "Weight on lookback 1", 1, 0, 5, 0.1 );Weight2 = Param( "Weight on lookback 2", 1, 0, 5, 0.1 );Weight3 = Param( "Weight on lookback 3", 1, 0, 5, 0.1 );
MinPrice = Param( "Minimum close price", 5, 0, 500, 0.5 );MinTurnover = Param( "Minimum median turnover", 2000000, 0, 50000000, 100000 );TurnPeriod = Param( "Turnover median period", 50, 5, 250, 1 );
TopCount = Param( "Mark the top N", 20, 1, 500, 1 );TieToggle = ParamToggle( "Tie mode", "1224 shared ranks|1234 consecutive ranks", 0 );ShowAll = ParamToggle( "Rows to show", "Ranked symbols only|Every symbol", 0 );
if( TieToggle ) TieMode = 1234; else TieMode = 1224;
// The longest lookback plus the turnover window is the shortest history a// symbol can have and still produce a defined score on the last bar.MinBars = Max( Max( Look1, Look2 ), Look3 ) + TurnPeriod;
// ---------------------------------------------------------------- functions// Defined once and called from both passes, so the eligibility rule that// decides who is ranked is literally the same code that explains why.// It reads the price arrays currently in force, which is what makes it work// unchanged inside a SetForeign block.function IsLiquidEnough( TurnoverFloor, MedianPeriod ){ local MedianTurnover; MedianTurnover = Median( Close * Volume, MedianPeriod ); return MedianTurnover >= TurnoverFloor;}
function IsEligible( PriceFloor, TurnoverFloor, MedianPeriod, BarsNeeded ){ local Liquid, Priced, LongEnough; Liquid = IsLiquidEnough( TurnoverFloor, MedianPeriod ); Priced = Close >= PriceFloor; LongEnough = BarIndex() >= BarsNeeded; return Liquid AND Priced AND LongEnough;}
// ----------------------------------------------------------------- universeApplySetting = GetOption( "ApplyTo" );
if( ApplySetting == 2 ){ WatchListNum = GetOption( "FilterIncludeWatchlist" ); SymbolList = CategoryGetSymbols( categoryWatchlist, WatchListNum );}else if( ApplySetting == 0 ){ SymbolList = CategoryGetSymbols( categoryAll, 0 );}else{ Error( "Set Apply to = All symbols, or Filter with an Include watch list." );}
// ------------------------------------------------------------------- PASS 1if( Status( "stocknum" ) == 0 ){ StaticVarRemove( InputPrefix + "*" ); StaticVarRemove( OutputPrefix + InputPrefix + "*" ); StaticVarRemove( ScoredName ); StaticVarRemove( IssuedName ); StaticVarRemove( BenchName );
// The benchmark is read once, not once per symbol. Its returns are on the // bar grid of this execution's symbol, and so is every symbol read below, // which is what makes the subtraction legitimate. BenchOk = SetForeign( BenchSymbol ); BenchC = Close; RestorePriceArrays();
StaticVarSet( BenchName, BenchOk );
if( NOT BenchOk ) Error( "Benchmark symbol " + BenchSymbol + " is not in this database." );
BenchRoc1 = ROC( BenchC, Look1 ); BenchRoc2 = ROC( BenchC, Look2 ); BenchRoc3 = ROC( BenchC, Look3 );
WeightSum = Max( Weight1 + Weight2 + Weight3, 0.0001 );
for( i = 0; ( Sym = StrExtract( SymbolList, i ) ) != ""; i++ ) { if( SetForeign( Sym ) ) { Eligible = IsEligible( MinPrice, MinTurnover, TurnPeriod, MinBars );
SymRoc1 = ROC( Close, Look1 ); SymRoc2 = ROC( Close, Look2 ); SymRoc3 = ROC( Close, Look3 );
RestorePriceArrays();
// Excess return over the benchmark on each window, blended by the // chosen weights. Positive means the symbol outpaced the // benchmark over that window; it says nothing about what follows. Blend = ( Weight1 * ( SymRoc1 - BenchRoc1 ) + Weight2 * ( SymRoc2 - BenchRoc2 ) + Weight3 * ( SymRoc3 - BenchRoc3 ) ) / WeightSum;
// Ineligible bars get Null rather than a number, and a symbol that // is ineligible on every bar still gets a variable so that the // self-check below can count it. Ranking behaviour for Null input // is not documented, which is why the check exists. Score = IIf( Eligible, Blend, Null );
StaticVarSet( InputPrefix + Sym, Score ); } }
StaticVarGenerateRanks( OutputPrefix, InputPrefix, 0, TieMode );
// -------------------------------------------------------- self-check HighestIssued = 0; ScoredSymbols = 0;
for( i = 0; ( Sym = StrExtract( SymbolList, i ) ) != ""; i++ ) { ThisScore = StaticVarGet( InputPrefix + Sym ); ThisRank = StaticVarGet( OutputPrefix + InputPrefix + Sym );
HighestIssued = Max( HighestIssued, Nz( ThisRank ) ); ScoredSymbols = ScoredSymbols + ( NOT IsNull( ThisScore ) ); }
StaticVarSet( ScoredName, ScoredSymbols ); StaticVarSet( IssuedName, HighestIssued );}
// ------------------------------------------------------------------- PASS 2// Read once into ordinary variables and do the arithmetic on those.OwnScore = StaticVarGet( InputPrefix + Name() );OwnRank = StaticVarGet( OutputPrefix + InputPrefix + Name() );Scored = StaticVarGet( ScoredName );Issued = StaticVarGet( IssuedName );
// Recomputed locally from this symbol's own data - cheap, and it lets the// table say why a symbol is absent from the ranking. The names differ from// the ones inside the functions above, which are local to those functions.SelfTurnover = Median( Close * Volume, TurnPeriod );SelfLiquid = SelfTurnover >= MinTurnover;SelfPriced = Close >= MinPrice;SelfLongEnough = BarIndex() >= MinBars;
RankPercent = 100 * OwnRank / Max( Scored, 1 );
StatusText = WriteIf( NOT IsNull( OwnRank ), "ranked", WriteIf( NOT SelfLiquid, "excluded: thin", WriteIf( NOT SelfPriced, "excluded: low price", WriteIf( NOT SelfLongEnough, "excluded: short history", "excluded: no score" ) ) ) );
Filter = IIf( ShowAll, 1, NOT IsNull( OwnRank ) );
AddColumn( OwnRank, "Rank", 1.0 );AddColumn( OwnScore, "Blended excess %", 1.2 );AddColumn( RankPercent, "Rank as % of field", 1.1 );AddColumn( SelfTurnover, "Median turnover", 1.0 );AddColumn( Close, "Close", 1.2 );AddColumn( Scored, "Symbols ranked", 1.0 );AddColumn( Issued, "Highest rank issued", 1.0 );AddTextColumn( StatusText, "Status" );AddTextColumn( WriteIf( OwnRank <= TopCount, "TOP " + NumToStr( TopCount, 1.0 ), "" ), "Selection" );
// Column 1 is the ticker, column 2 the date, so Rank is column 3.SetSortColumns( 3 );How it works
Section titled “How it works”The header and the parameters. Every threshold, weight and lookback is a Param(), so
the tool can be re-pointed from the Analysis window’s Parameters button without editing
code. MinBars is derived rather than entered: it is the longest lookback plus the
turnover window, which is the shortest history that lets both the score and the liquidity
test be defined on the same bar.
The two functions. IsLiquidEnough() and IsEligible() are declared with the
function keyword and use local for their working variables. They read whichever price
arrays are currently in force, which is what lets the same code run inside a SetForeign()
block in pass one and on the symbol’s own data in pass two. That matters more than it
looks: the rule that decides who is ranked and the text that explains why a symbol was
excluded are then guaranteed to be the same rule.
The universe guard. Three branches on GetOption( "ApplyTo" ): 2 for a filter, 0 for
all symbols, and Error() for anything else, which stops execution with a message rather
than producing an empty table.
Pass one. Inside Status( "stocknum" ) == 0, so AmiBroker runs it on the first symbol,
on one thread, and waits for it before launching the rest. It removes its own variables,
reads the benchmark once with SetForeign() — checking the documented return value, and
stopping with a message if the benchmark is missing — and computes the benchmark’s three
rates of change. Then it loops the universe: switch to the symbol, test eligibility,
compute three rates of change, restore the arrays, blend, blank the ineligible bars with
Null, and store.
Note that the benchmark’s rates of change are computed once, outside the loop. Reading the benchmark 500 times would produce identical arrays 500 times at 500 times the cost.
The ranking call. Once, with topranks set to 0 for normal mode and the tie mode from
the toggle.
The self-check. A second loop over the same universe, reading back from the store
rather than from the database. It records the highest rank the function issued and how
many symbols had a non-Null score, and stores both. This is cheap — it touches no price
data at all — and it is what turns “the table looks reasonable” into “the table is
consistent with the number of symbols that had scores”.
Pass two. Runs for every symbol. Reads its own score and rank, recomputes its own
eligibility locally so the status text can explain an absence, computes the rank as a
percentage of the field, and builds the table. SetSortColumns( 3 ) sorts by rank
ascending, so rank 1 is the top row.
Key functions
Section titled “Key functions”StaticVarGenerateRanks( "outputprefix", "inputprefix", topranks, tiemode )— the ranking call.topranks = 0is normal mode, in which every symbol gets its own rank array namedoutputprefix + inputprefix + symbol.SetForeign( ticker, fixup = True, tradeprices = False )— returns 1 if the ticker exists and 0 otherwise, leaving the arrays untouched on failure. Both the benchmark check and the per-symbol loop use that return value.RestorePriceArrays( tradeprices = False )— mandatory after everySetForeign(), including inside the loop body.Status( "stocknum" )— 0 on the first symbol of the run; the documented guard for once-per-run work.GetOption( "ApplyTo" ),GetOption( "FilterIncludeWatchlist" )— the Analysis window’s own settings, read natively.Median( array, period )— the liquidity measure.WriteIf( EXPRESSION, "TRUE TEXT", "FALSE TEXT" )— nested here to build one status string from several conditions.
Reading the output
Section titled “Reading the output”| Column | What it is | What it is for |
|---|---|---|
| Rank | Position in the universe, 1 is strongest | The answer |
| Blended excess % | The score the rank came from | Tells you whether rank 1 is strong or merely least weak |
| Rank as % of field | Rank divided by symbols ranked | Comparable across universes of different sizes |
| Median turnover | The liquidity figure used | Shows how close to the threshold a row is |
| Close | Last price | Sanity check against the price floor |
| Symbols ranked | How many had a real score | Self-check, same on every row |
| Highest rank issued | Largest rank the function produced | Self-check, same on every row |
| Status | ranked, or why not | Explains absences |
| Selection | Marks the top N | Convenience |
The two self-check columns are the ones to look at first, before the ranks. They should
hold the same value on every row, and the highest rank issued should equal the number of
symbols ranked, or be a little smaller when tie mode 1224 has compressed the range.
Validation procedure
Section titled “Validation procedure”Do all six steps once, in order. They take about twenty minutes and they are the difference between a tool and a decoration.
1. Prove the arithmetic on one symbol. Set the range to one recent bar and run on a watch list of six symbols. Pick a row. Read that symbol’s close on the bar and its close 63 bars earlier from its chart; compute the percentage change by hand. Do the same for the benchmark. Subtract. Repeat for 126 and 252 bars, average the three, and compare with the Blended excess column. It should match to the displayed precision.
2. Prove the ordering. Copy the Blended excess and Rank columns into a spreadsheet, sort your copy descending by score, and number the rows. Your numbering and the Rank column must agree. If they are reversed, your build ranks lowest-first and you should negate the stored score.
3. Prove the cleanup. Note StaticVarCount() in the inspector formula from the third
lesson. Run the tool. Run it again. The count must be the same after the second run as
after the first. If it grows, something is being written that is not being removed.
4. Prove the universe. Remove one symbol from the watch list and re-run. “Symbols ranked” must fall by exactly one, and the removed symbol must vanish from the table. If it is still there, you are reading stale static variables.
5. Prove the eligibility. Set the minimum turnover to an absurdly high value and re-run with “Every symbol” selected. Every row should now read “excluded: thin”, and “Symbols ranked” should be zero. Set it back.
6. Prove the history handling. Add a recently listed symbol with less than a year of data. It should appear as “excluded: short history” and take no rank. Then shorten the longest lookback below its history and confirm it joins the ranking.
Common errors
Section titled “Common errors”- Every symbol excluded as “thin”. The turnover threshold is in the currency units of
Close * Volumein your database. A database of index-level or foreign-currency data needs a different number, not a different formula. - The formula stops with “Benchmark symbol is not in this database”. That is the check working. Fix the ticker in the parameters, or add the symbol.
- Every rank is
Nullalthough scores exist. The read-back name is wrong. It isOutputPrefix + InputPrefix + Name(), both prefixes. - Highest rank issued exceeds symbols ranked. Stale static variables are joining the
ranking, or something else in your AmiBroker is writing names that start with
RsRankScore. Check with the inspector. - Ranks that stop part way back through history. A static array stores only the bars currently in use, so the Analysis range and QuickAFL decide how far back the ranking goes. Widen the range if you need more.
- Ranks that look wrong only in the distant past. Everything in pass one is aligned to the bar grid of the first symbol processed. If that symbol has a short or gappy history, the whole ranking inherits it. Turn on “Pad and align all data to reference symbol” in Analysis settings — the manual notes it is off by default and warns that it can slightly change indicator values where there are data holes — and choose a reference symbol with complete history.
- The run takes minutes. Pass one is doing 500 foreign reads and one ranking call;
that part is unavoidable. What is avoidable is running it on every symbol: check that
the
Status( "stocknum" ) == 0guard is present and is not inside an#includefile, where the documentation states it will not be detected. - Different results on Standard and Professional. The results should be identical. The editions differ only in how many threads an Analysis window may use — 2 against 32 — so they differ in how long the run takes. If the numbers differ, you have a synchronisation problem: something is writing static variables outside the guard.
Extensions
Section titled “Extensions”Sector rotation research
Section titled “Sector rotation research”The most natural extension is to change what is being ranked. Instead of ranking shares against a market index, rank sectors against it. Build one symbol per sector — a sector index if your data source provides one, a sector ETF if not, or a composite you construct yourself with the techniques in Part 16 — put them in a watch list, and run the same tool over that list with the market index as the benchmark.
The output is a sector strength table. Two honest cautions before you read anything into it. First, sector proxies are not sectors: an ETF has its own weighting, its own fees and its own liquidity, and a composite you build has whatever survivorship properties your database has. Second, a sector ranking has a very small effective sample size — ten or eleven sectors, changing slowly — so a pattern you notice in it is supported by far less independent evidence than the number of bars suggests. Part 30 is about exactly this trap.
A second version worth building: rank symbols within their own sector rather than
across the whole market, by running the tool once per sector watch list, or by including
SectorID( 1 ) as a text column and reading the table sector by sector. That answers
“which are the strongest miners?” rather than “which are the strongest shares?”, and the
two questions have different uses.
A risk-adjusted score
Section titled “A risk-adjusted score”Divide the blended excess return by the symbol’s volatility over the same window — the
standard deviation of daily returns, or ATR() as a fraction of price — before storing it.
Ranking on return alone quietly ranks volatility as well, because a more volatile symbol
covers more ground in either direction. Whether the risk-adjusted ranking is better is
an empirical question you now have two tools to compare.
Rank stability
Section titled “Rank stability”Store the rank array and report Ref( OwnRank, -20 ) and the change alongside today’s
rank. A score whose ranking reshuffles completely every month is measuring something very
short-lived, whatever window you computed it over. This is also the cheapest way to
estimate how much turnover any selection built on the ranking would generate.
A dated snapshot
Section titled “A dated snapshot”Write the day’s top N to a text static variable with StaticVarSetText(), or export the
Exploration to a file with a dated name, and keep the record. A ranking tool that
overwrites its own history can never be checked against what it actually said at the time.
Building that habit now costs nothing and is the raw material for every out-of-sample
question in Part 32.
Why a ranking is not a strategy
Section titled “Why a ranking is not a strategy”The tool works, the ranks are verified, and the top row of the table is the strongest name in your universe on your measurement. It is worth being blunt about how far that is from something you could trade.
There is no entry. A rank says where a symbol stands, not when to act. Rank 1 today may have been rank 1 for six months.
There is no exit. Nothing in the tool says what happens when a holding falls to rank 40, or to rank 200, or is delisted.
There is no position sizing and no risk control. Twenty names ranked by momentum is a concentration decision made by accident. Part 34 is about making it on purpose.
There are no costs. Every ranking implies turnover, every turnover implies spread and commission, and short holding periods are where costs do the most damage. A ranking that looks strong before costs can be anything at all after them.
The universe is a decision. Ranking today’s index members over five years of history ranks a set chosen with hindsight. Part 30 explains why that is not a small effect.
The measurement is entirely backward-looking. The blended excess return describes what already happened. Whether the ordering it produces has any relationship to what happens next is a question, not a premise, and answering it needs the whole apparatus of Parts 27 to 33: a stated hypothesis, a point-in-time universe, a holding rule, a cost model, a comparison against the base rate of holding the universe unselected, and out-of-sample evidence.
You have built a two-pass cross-sectional tool: a guarded universe read, a single-threaded scoring pass, one ranking call, a self-check that reads its own output back, and a per-symbol reporting pass. The design choices — excess over a benchmark, three blended windows, eligibility before ranking, median turnover, a long private prefix, no persistence — are all visible in the formula and all changeable.
More importantly, you have a validation procedure that would catch the tool being wrong,
and a clear statement of what the output is not. Every part after this one that ranks
anything — portfolio backtesting with PositionScore in Part 28, rotational research in
Part 35 — assumes you can produce a ranking you have checked. You can now.
Check your understanding
Sources for this lesson
7 verified · checked 2026-08-31
- 01AmiBroker User's Guide - Ranking functionalityamibroker.com/guide/h_ranking.html2026-08-31
- 02AFL Function Reference - StaticVarGenerateRanksamibroker.com/guide/afl/staticvargenerateranks.html2026-08-31
- 03AFL Function Reference - StaticVarSetamibroker.com/guide/afl/staticvarset.html2026-08-31
- 04AFL Function Reference - SetForeignamibroker.com/guide/afl/setforeign.html2026-08-31
- 05AmiBroker User's Guide - Analysis settings§ Pad and align all data to reference symbolamibroker.com/guide/w_settings.html2026-08-31
- 06AmiBroker User's Guide - Efficient use of multithreadingamibroker.com/guide/h_multithreading.html2026-08-31
- 07AmiBroker User's Guide - Categories windowamibroker.com/guide/w_categories.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.