Skip to content
Level 3 · AFL DeveloperProjectPart 10 · page 8 of 860 min
60Minutes
18AFL functions
6Sources
StandardRequires
AFL functions taught here18

Project: Multi-Indicator Analysis Panel

Build a single chart pane that shows three different readings of the same instrument at once — where price sits relative to its trend, how momentum is behaving, and whether the current range is wide or narrow by that instrument’s own standards — with each reading in its own horizontal band, correctly scaled, readable on a phone, and clearly labelled.

By the end you will have solved a real AFL problem that has no built-in solution: putting several series with completely different natural ranges into one pane without any of them squashing the others flat.

You will also have built the thing this part has been leading to, and the thing the capstone’s market dashboard is a bigger version of.

Put an RSI (0 to 100) and a distance-from-trend reading (roughly −5 to +5) in the same pane with normal scaling and you get a flat line at the bottom of the chart and nothing else. The axis has to cover both, so the smaller series occupies a pixel.

AFL gives you exactly one lever over an individual plot’s vertical scale, and the documentation is precise about it: minvalue and maxvalue are the fifth and sixth arguments of Plot(), and they are used by styleOwnScale plots only. That is the whole toolkit. There is no “place this plot in the top third of the pane” option.

So we build one.

Think of the pane as running from 0 at the foot to 1 at the top. You want a series whose data runs from DataMin to DataMax to occupy the slice from BandBottom to BandTop.

With scale bounds of ScaleMin to ScaleMin + Span, a value v is drawn at the pane fraction:

Pseudocode — not valid AFL

fraction( v ) = ( v - ScaleMin ) / Span

You need two things to be true at once:

Pseudocode — not valid AFL

fraction( DataMin ) = BandBottom
fraction( DataMax ) = BandTop

Subtract the first from the second and ScaleMin cancels, leaving Span. Substitute back and you get ScaleMin:

Pseudocode — not valid AFL

Span = ( DataMax - DataMin ) / ( BandTop - BandBottom )
ScaleMin = DataMin - BandBottom * Span

That is two lines of arithmetic, and it is the entire mechanism. Every band in the panel — the data series, the reference lines, the zero line — is drawn by passing its own DataMin, DataMax, BandBottom and BandTop through those two lines and into a styleOwnScale plot.

One pane, three bands

  1. Trend band — 0.70 to 1.00Distance from a long moving average, measured in ATR units and clipped to ±4. Histogram, coloured by sign, with a zero line.
  2. Momentum band — 0.36 to 0.64RSI on its native 0–100 scale, with dashed reference lines at 30 and 70 and a solid one at 50.
  3. Volatility band — 0.00 to 0.28PercentRank of ATR-as-a-percentage-of-price over its own recent history, 0–100, with 20 and 80 marked.
Each band is an independent styleOwnScale plot whose min/max bounds are computed so that its data lands inside its own slice of the pane.

The gaps between the bands are deliberate. 0.64 to 0.70 and 0.28 to 0.36 are empty space, and empty space is what stops a spike in one band from being mistaken for part of the one below it.

Why the three readings, and why they are not added together

Section titled “Why the three readings, and why they are not added together”

The panel shows trend, momentum and volatility because those are three genuinely different questions:

  • Where is price relative to where it has been? — a trend question
  • How has it been getting there? — a momentum question
  • How large are the moves, by this instrument’s own standards? — a volatility question

What the panel deliberately does not do is combine them into a score.

That is why the non-compact title ends with a sentence stating it. A tool that could be misread should say what it is out loud, on screen, where the misreading would happen.

Complete runnable AFL

analysis-panel.afl
/*
* Multi-indicator analysis panel - Part 10 project.
*
* What it does
* Puts three separate readings in one pane, each in its own horizontal band:
* top - trend: how far price sits from a long average, in ATR units;
* middle - momentum: RSI, with its 30/50/70 reference lines;
* bottom - volatility: where ATR-as-a-percentage-of-price sits inside its
* own recent history.
*
* What it deliberately does not do
* It does not add the three readings together, score them, or call their
* agreement "confirmation". All three are transformations of the same close
* series, so they agree with each other for arithmetic reasons far more often
* than three independent measurements would. The panel puts them side by side
* so you can see the disagreement, which is the part that carries information.
*
* Assumptions and limits
* - Insert as its OWN pane, below the price chart.
* - Nothing here is a signal, a score or a forecast.
* - RSI is Null for its first periods bars; ATR and the averages are
* seed-contaminated for longer than that. Read the left edge of the chart
* with suspicion.
*/
_SECTION_BEGIN( "Analysis panel" );
TrendPeriod = Param( "Trend average periods", 100, 20, 400, 5 );
AtrPeriod = Param( "ATR periods", 20, 2, 100, 1 );
RsiPeriod = Param( "RSI periods", 14, 2, 100, 1 );
RankPeriod = Param( "Volatility look-back (bars)", 252, 20, 1000, 1 );
StretchCap = Param( "Trend band limit (ATR units)", 4, 1, 12, 0.5 );
Compact = ParamToggle( "Compact (small screens)", "No|Yes", 0 );
UpTint = ParamColor( "Positive colour", colorBrightGreen );
DownTint = ParamColor( "Negative colour", colorRed );
QuietTint = ParamColor( "Neutral colour", colorBlueGrey );
GuideTint = ParamColor( "Guide line colour", colorLightGrey );
/*
* Draws one series inside a horizontal slice of the pane.
*
* minvalue and maxvalue are documented as being used by styleOwnScale plots
* only, and they are the sole control AFL gives over a plot's own Y range. So a
* band is produced by choosing bounds that place DataMin at BandBottom and
* DataMax at BandTop, where both band figures are fractions of the pane height
* running 0 at the foot to 1 at the top.
*
* With scale bounds ScaleMin..ScaleMin+Span, a value v sits at the pane
* fraction ( v - ScaleMin ) / Span. Substituting v = DataMin gives BandBottom
* and v = DataMax gives BandTop, which is the whole trick.
*/
function PlotInBand( DataSeries, PlotName, PlotTint, StyleBits,
DataMin, DataMax, BandBottom, BandTop )
{
local Span, ScaleMin;
Span = ( DataMax - DataMin ) / ( BandTop - BandBottom );
ScaleMin = DataMin - BandBottom * Span;
Plot( DataSeries, PlotName, PlotTint,
StyleBits | styleOwnScale | styleNoLabel,
ScaleMin, ScaleMin + Span );
return Span;
}
// ---------------------------------------------------------------- trend band
TrendLine = MA( Close, TrendPeriod );
Volatility = ATR( AtrPeriod );
// Distance from the average measured in ATR units, so the number means the same
// thing on any instrument, then clipped so one extreme bar cannot squash the
// rest of the band flat.
Stretch = ( Close - TrendLine ) / Volatility;
Stretch = Max( Min( Stretch, StretchCap ), -StretchCap );
StretchTint = IIf( Stretch > 0, UpTint, DownTint );
PlotInBand( 0, "", GuideTint, styleLine | styleNoTitle,
-StretchCap, StretchCap, 0.70, 1.00 );
PlotInBand( Stretch, "Distance from trend (ATR units)", StretchTint,
styleHistogram | styleThick,
-StretchCap, StretchCap, 0.70, 1.00 );
// ------------------------------------------------------------- momentum band
Momentum = RSI( RsiPeriod );
PlotInBand( 30, "", GuideTint, styleLine | styleDashed | styleNoTitle, 0, 100, 0.36, 0.64 );
PlotInBand( 50, "", GuideTint, styleLine | styleNoTitle, 0, 100, 0.36, 0.64 );
PlotInBand( 70, "", GuideTint, styleLine | styleDashed | styleNoTitle, 0, 100, 0.36, 0.64 );
PlotInBand( Momentum, "RSI " + NumToStr( RsiPeriod, 1.0 ), QuietTint,
styleLine | styleThick, 0, 100, 0.36, 0.64 );
// ----------------------------------------------------------- volatility band
AtrPercent = 100 * ATR( AtrPeriod ) / Close;
Rank = PercentRank( AtrPercent, RankPeriod );
RankTint = IIf( Rank >= 80, DownTint, IIf( Rank <= 20, UpTint, QuietTint ) );
PlotInBand( 20, "", GuideTint, styleLine | styleDashed | styleNoTitle, 0, 100, 0.00, 0.28 );
PlotInBand( 80, "", GuideTint, styleLine | styleDashed | styleNoTitle, 0, 100, 0.00, 0.28 );
PlotInBand( Rank, "Volatility percentile", RankTint,
styleHistogram, 0, 100, 0.00, 0.28 );
// ------------------------------------------------------------------- reading
SetChartOptions( 2, chartWrapTitle );
TrendWord = WriteIf( Stretch > 0.5, "above trend",
WriteIf( Stretch < -0.5, "below trend", "at trend" ) );
VolatilityWord = WriteIf( Rank >= 80, "wide range",
WriteIf( Rank <= 20, "narrow range", "ordinary range" ) );
if( Compact )
_N( Title = StrFormat( "%s %s RSI %g vol pct %g",
Name(), TrendWord,
SelectedValue( Momentum ), SelectedValue( Rank ) ) );
else
_N( Title = StrFormat(
"%s trend: %s (%g ATR) momentum: RSI(%g) = %g volatility: %s, percentile %g\n"
+ "Three views of one price series. Agreement between them is not independent evidence.",
Name(), TrendWord, SelectedValue( Stretch ),
RsiPeriod, SelectedValue( Momentum ),
VolatilityWord, SelectedValue( Rank ) ) );
_SECTION_END();

Download analysis-panel.afl134 lines

The only function in the file. It takes the series, its name, its colour, its style bits, the data range you want mapped, and the band you want it mapped into. It computes Span and ScaleMin from the four numbers, calls Plot() with styleOwnScale | styleNoLabel added to whatever style you passed, and returns Span so a caller could reuse it.

local Span, ScaleMin; matters. Without it, a variable assigned inside a function is visible outside it, and two calls would interfere. Part 11 takes scope apart properly; here, just note that a reusable function that does not declare its locals is a bug waiting for its second caller.

Fragment — not a complete formula

Stretch = ( Close - TrendLine ) / Volatility;
Stretch = Max( Min( Stretch, StretchCap ), -StretchCap );

Dividing the distance from the average by ATR is what makes the number portable. “Two dollars above the average” means nothing without knowing the instrument; “two ATRs above the average” means the same thing on a $4 stock and a $400 one, which is what lets you compare two symbols by eye.

The clip is a display decision, not an analytical one. One extreme bar with a Stretch of 20 would force the band’s scale to cover ±20 and flatten every other bar to a stub. Clipping at ±4 keeps the band readable, and the cost — that you cannot tell 4 from 12 — is acceptable in a panel whose job is orientation. Say so in the parameter name, which is why it is called “Trend band limit” rather than something that implies the data was changed for analytical reasons.

The zero line is plotted first, with the same bounds, so the histogram is drawn over it.

RSI already lives on 0 to 100, so the band bounds are the natural ones. The three reference lines are constants pushed through PlotInBand(): 30 and 70 dashed, 50 solid. The 50 line is the one that actually matters — it is the midpoint of the oscillator — and it is drawn solid for that reason.

Fragment — not a complete formula

AtrPercent = 100 * ATR( AtrPeriod ) / Close;
Rank = PercentRank( AtrPercent, RankPeriod );

Two normalisations stacked. Dividing ATR by price makes it comparable across instruments; PercentRank( array, range ) then reports where the current value sits within its own last range values, on a 0–100 scale. So a reading of 90 means “wider range than 90% of the last 252 bars for this symbol” — a statement that needs no knowledge of the instrument to interpret.

PercentRank needs its full lookback before it means anything. With the default of 252 bars, the first year of any chart is telling you about a shorter and shorter window, and the very left edge is meaningless. That is the same warm-up problem as every other lookback function, and the header comment says so.

SetChartOptions( 2, chartWrapTitle ) uses mode 2 — set flag — which ORs chartWrapTitle into the pane’s existing options rather than overwriting them. That is the polite mode: it turns on title wrapping without silently resetting anything the user configured in the Parameters dialog.

The Compact toggle exists because the full title is two lines and will wrap to four or five on a phone. _N() wraps the title assignment so AmiBroker does not try to interpret the embedded formatting as something to evaluate.

Verify the band mapping is actually doing what you think

Section titled “Verify the band mapping is actually doing what you think”

This is the part worth checking, because if the arithmetic is wrong the panel still looks plausible.

Temporarily add a plot of the constant 100 into the momentum band:

Fragment — not a complete formula

PlotInBand( 100, "top of momentum band", colorRed, styleLine, 0, 100, 0.36, 0.64 );

It must land exactly on the top edge of the momentum band — at 64% of the pane height, just below the gap. Do the same with 0; it must land on the bottom edge at 36%. If either misses, Span or ScaleMin is wrong. Remove both when you are satisfied.

Pick a bar and read the title’s ATR-unit figure. Then confirm it by hand: hover the price chart to get the close, subtract the moving-average value, divide by an ATR reading from a separate ATR pane. Within rounding, the numbers must agree. If your stretch figure is ten times too large, you divided by the wrong thing.

Verify the percentile means what the label says

Section titled “Verify the percentile means what the label says”

Set RankPeriod to a small number — 20 — and watch the bottom band. It should now spend far more time at the extremes, because a 20-bar window is easy to be the widest bar in. Set it to 1000 and it should become much steadier. If it does not respond to the parameter at all, the parameter is not reaching PercentRank().

Scroll to the extreme left edge of the chart. The volatility band should be empty or erratic for the first RankPeriod bars, and the trend band empty for the first TrendPeriod. If something is drawn there, you are looking at seeded values, not at data.

Everything collapses into one flat line. styleOwnScale is missing from one of the plots. Without it, minvalue/maxvalue are ignored — the documentation is explicit that they apply to own-scale plots only — and that plot joins the pane’s shared axis, dragging the axis to cover its full range.

The bands are in the wrong order vertically. BandBottom and BandTop are pane fractions with 0 at the foot. Passing 0.70, 1.00 puts a band at the top. Swapping them gives a negative Span and an upside-down plot.

Reference lines float away from their band. They were plotted with different DataMin/ DataMax values than the series they belong to. Every element of a band must use the same four numbers.

The title shows values from the wrong bar. SelectedValue() reads the bar under the crosshair or, with nothing selected, the last bar. If you expected a different bar, that is the reason.

One band’s colour array does not change. IIf() builds the colour array element by element; if you wrote a scalar comparison by accident — IIf( LastValue( Stretch ) > 0, … ) — every bar gets the same colour.

The panel is unreadable on a phone. Turn on the Compact toggle. If it is still cramped, reduce to two bands rather than shrinking the type: three bands need roughly 200 pixels of pane height to be legible.

Attempt these in order; each one is a genuine addition rather than a decoration.

  1. Make the band layout a parameter. Add a ParamList with “Three bands”, “Trend and momentum” and “Momentum only”, and compute the band boundaries from the choice. This forces you to stop hard-coding 0.70 and 0.36 and is a real refactor.

  2. Add a fourth band showing relative strength against a benchmark, using Foreign(). Part 13 builds the ratio properly. Note the new dependency: the panel now fails on a database without the benchmark symbol, so it needs a guard — which is exactly what the defensive AFL lesson is about.

  3. Show the disagreement explicitly. Add a title clause that fires only when the three readings diverge — for example price above trend while RSI is below 50. Resist the urge to turn it into a signal; the value is that it names a state you would otherwise scroll past.

  4. Move PlotInBand() into an include file so the next panel you build can use it. That is the personal library project, and this function is a good candidate for its first entry.

You now know the one lever AFL gives you over an individual plot’s vertical scale, and the two-line arithmetic that turns it into arbitrary band placement. You have a reusable function rather than three hard-coded scale calculations. And you have built a tool that presents three views of one price series without pretending they are three independent witnesses — which is a design decision, visible in the code and stated in the title, not an accident.

Check your understanding

Question 1. Why does every plot in this panel include styleOwnScale?
Plot( DataSeries, PlotName, PlotTint, StyleBits | styleOwnScale | styleNoLabel, ScaleMin, ScaleMin + Span );
Show the answer and why

Answer: Because minvalue and maxvalue are documented as being used by styleOwnScale plots only — without it the scale arguments are ignored

The Plot() reference states that minvalue and maxvalue apply to styleOwnScale plots ONLY. Remove it and the plot rejoins the pane's shared axis, which is exactly the problem the band technique exists to solve. styleNoLabel is what suppresses the axis label; styleNoTitle suppresses the title entry.

Question 2. A reading of 90 in the volatility band means what, precisely?
AtrPercent = 100 * ATR( AtrPeriod ) / Close;
Rank       = PercentRank( AtrPercent, RankPeriod );
Show the answer and why

Answer: Current ATR-as-a-percentage-of-price is higher than 90% of its own readings over the lookback window

PercentRank reports where the current element sits within the last `range` elements of the same array. It is a statement about this symbol against its own recent history — nothing about other instruments, and nothing at all about what happens next.

Question 3. You want a band occupying the top quarter of the pane for data that ranges 0 to 50. Which call is correct?
Show the answer and why

Answer: PlotInBand( x, "n", c, s, 0, 50, 0.75, 1.00 )

The signature is ( series, name, colour, style, DataMin, DataMax, BandBottom, BandTop ). Data bounds first, then pane fractions with 0 at the foot. Swapping BandBottom and BandTop gives a negative Span and an inverted plot.

Question 4. Which statements about combining the three readings are accurate? Select all that apply.
Show the answer and why

Answer: All three are computed from the same close series, so their agreement is partly arithmetic rather than evidential, Disagreement between the readings carries more information than agreement, Genuine confirmation would require information the panel does not currently use, such as breadth, volume or a second instrument

Three transformations of one series are not three witnesses. Collapsing them into a score discards the disagreement, which is the part that is not already implied by the price trend, and it hides the fact that the inputs were never independent.

Question 5. What does SetChartOptions( 2, chartWrapTitle ) do that SetChartOptions( 1, chartWrapTitle ) would not?
Show the answer and why

Answer: Mode 2 sets the flag by binary-OR, leaving other pane options unchanged; mode 1 overwrites them

The documented modes are 0 set-defaults, 1 overwrite, 2 set-flag (binary OR with current values), 3 reset-flag. Mode 2 is the non-destructive one, so it does not silently discard settings the user chose in the Parameters dialog.

Sources for this lesson

6 verified · checked 2026-08-31

  1. 01AFL Function Reference — Plot§ minvalue and maxvalue (used by styleOwnScale plots ONLY)amibroker.com/guide/afl/plot.html2026-08-31
  2. 02AFL Function Reference — PercentRankamibroker.com/guide/afl/percentrank.html2026-08-31
  3. 03AFL Function Reference — SetChartOptionsamibroker.com/guide/afl/setchartoptions.html2026-08-31
  4. 04AFL Function Reference — ATRamibroker.com/guide/afl/atr.html2026-08-31
  5. 05AFL Function Reference — RSIamibroker.com/guide/afl/rsi.html2026-08-31
  6. 06AmiBroker User's Guide — User-defined functionsamibroker.com/guide/a_userfunctions.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.