You have a watchlist of twenty symbols and a simple question: which of them lined up this morning? Opening twenty charts, each with the same three indicators, is slow and error-prone, and by the time you reach the last one the first has changed. A scanner solves this by evaluating the same rules on every symbol and printing the result in one table on a single chart. Pine Script v6 can do this in one script, but only if you design around a few constraints that are not obvious from the function reference.
This guide builds a complete multi-symbol scanner step by step. It reads a list of symbols from a text box, requests a bundle of metrics for each one, avoids repainting, ranks the results, draws them in a table, and sends an alert when a symbol crosses a condition on the last closed bar. Along the way it explains why each choice is made, because the scanners that break in practice almost always break at one of those decision points.
What a scanner is in Pine Script terms
A Pine Script indicator runs in the context of one chart symbol. Everything it knows about any other symbol arrives through a request.*() function, most often request.security(). A scanner is therefore a loop that calls request.security() once per symbol and collects what comes back. Before v6, the symbol argument had to be a simple string known before the script ran, which meant a scanner was a pile of copy-pasted request calls, one per symbol, with no way to build the list from an input.
Pine Script v6 changed that. Request functions accept series strings and can sit inside loops and conditional blocks, so you can iterate over an array of symbol strings and request each one in turn. The dynamic requests guide covers that mechanism in detail. This post takes it as the starting point and focuses on what you build on top of it: the structure of a scanner you can keep extending without it falling over.
Two facts shape the whole design. First, the script still executes once per bar on the chart, so your loop runs on every historical bar, not only on the last one. Second, the number of request calls a script may make is capped, which turns your watchlist size into a budget.
The request budget decides your design
TradingView limits a script to 40 unique request.*() calls, and 64 on the Ultimate plan. Identical calls share a slot, while each distinct symbol, timeframe, or expression counts as a new one. A loop over thirty symbols that requests one expression each uses thirty of your forty. A loop over thirty symbols that requests close, then volume, then an RSI as three separate calls would need ninety, and the script stops with an error before it draws anything.
This is why a scanner should ask for everything it needs about a symbol in a single call. Pine Script lets request.security() evaluate a function and return a tuple, so you put every metric into one function, return them together, and spend exactly one request per symbol. The scanner below follows that rule, and the budget check is built in so that a long symbol list fails with a readable message instead of a confusing one.
The other half of the budget story is that the calls are not free in time either. Each requested symbol brings its own history into the script, and a scanner with many symbols on a long chart can approach the script execution time limit. If a scan feels slow, shorten the symbol list or load less history before reaching for cleverness. The post on why Pine Script gets slow goes through the usual causes, and the request.security explainer covers the call itself.
Turning a text box into a symbol list
The first piece is the input. Hardcoding symbols into the script means editing code every time the watchlist changes. A text area lets you paste a comma separated list into the settings dialog, and the script splits it into an array of strings. This first script does only that and prints the last close for each symbol, so you can confirm the plumbing works before adding anything else.
//@version=6
indicator("Scanner Part 1: Symbol List", overlay = true)
string rawList = input.text_area("BINANCE:BTCUSDT, BINANCE:ETHUSDT, BINANCE:SOLUSDT", "Symbols (comma separated)")
var array<string> symbols = array.new<string>()
if barstate.isfirst
for item in str.split(rawList, ",")
string clean = str.trim(item)
if clean != ""
symbols.push(clean)
var table board = table.new(position.top_right, 2, 21, border_width = 1)
for [i, sym] in symbols
float px = request.security(sym, timeframe.period, close)
if barstate.islast and i < 20
table.cell(board, 0, i + 1, sym)
table.cell(board, 1, i + 1, str.tostring(px, "#.####"))
if barstate.islast
table.cell(board, 0, 0, "Symbol", text_color = color.white, bgcolor = color.gray)
table.cell(board, 1, 0, "Last close", text_color = color.white, bgcolor = color.gray)A few details are worth slowing down for. The array is declared with var and filled only on the first bar, because the list does not change while the script runs, and rebuilding it on every bar would waste time. Each entry is trimmed and empty entries are skipped, so a trailing comma or a space after a comma does not produce a request for an empty symbol. Symbols are written with the exchange prefix, such as BINANCE:BTCUSDT, because the same ticker can exist on several exchanges with different prices and different history.
The loop uses the for [i, sym] in symbols form instead of for i = 0 to symbols.size() - 1. The counted form has a trap that bites scanners specifically: when the array is empty, symbols.size() - 1 is -1, and a Pine Script counted loop from 0 to -1 does not skip. It counts downward and runs twice, reading indexes that do not exist. The for...in form simply runs zero times on an empty array.
Finally, the request runs on every bar while the table is written only on the last bar. The request has to execute on every bar so that the history of each requested expression is computed properly. Only the drawing is restricted to the last bar, because a table only needs to show the current state.
One function, one request, many metrics
Now the real scanner logic. Decide what each symbol should be measured on, write that as a function, and let request.security() run the function in each symbol's own context. Everything inside the function, including every ta.* call, is calculated on the requested symbol's data, not the chart's.
For this example the scanner computes a fast and a slow exponential moving average, an RSI, and whether the fast average just crossed above the slow one. From those it builds a small alignment count between zero and three: close above the slow average, fast average above slow average, and RSI above 50. This count is a description of the current indicator state, nothing more. It does not predict anything, and you would replace it with whatever conditions your own rules define.
//@version=6
indicator("Scanner Part 2: Metrics Function", overlay = false)
scanMetrics(int fLen, int sLen, int rLen) =>
float fast = ta.ema(close, fLen)
float slow = ta.ema(close, sLen)
float rsi = ta.rsi(close, rLen)
bool crossedUp = ta.crossover(fast, slow)
int score = (close > slow ? 1 : 0) + (fast > slow ? 1 : 0) + (rsi > 50 ? 1 : 0)
[close[1], rsi[1], score[1], crossedUp[1]]
[px, rsiVal, score, crossed] = scanMetrics(20, 50, 14)
plot(score, "Alignment count (chart symbol)")Run on its own, the function above simply plots the alignment count for the chart symbol. Its last line returns a tuple of four values, and every value is read with [1], which is the previous bar's value. That detail is the next section's subject. For now, notice that the function holds all the logic in one place. Adding a fifth metric means adding one value to the tuple and one more array on the receiving side, without touching the request count.
Notice also that the ta.* calls sit at the top level of the function body, not inside an if or a ternary. Functions like ta.ema and ta.rsi keep internal state from bar to bar, so they must run on every bar. Hiding one behind a condition makes it skip bars and silently return wrong numbers. The lazy evaluation of and and or in v6 makes this easy to do by accident, and the lazy evaluation post shows how it happens.
Keeping the scan from repainting
A scanner that shows one thing while the bar is open and another after it closes is worse than no scanner, because you will act on a table that quietly changes. Requesting another symbol's current bar returns values that update on every tick, and on historical bars the results can differ from what you saw live.
The usual remedy is to request values from the previous closed bar and set lookahead = barmerge.lookahead_on. The [1] offset inside the tuple is what makes lookahead safe here. With the offset, the value being looked up at each bar is already final, so turning lookahead on does not leak future data into history. Without the offset, lookahead_on is the classic way an indicator ends up looking perfect on past bars and wrong live, a problem explained in the lookahead post and in the guide on multi-timeframe indicators that do not repaint.
The cost of this approach is a one bar delay. The table always describes the last closed bar of each symbol, never the bar still forming. For a scanner that is usually the right trade, since you want a state that will not change under you. Say so in your own notes on the script, because a trigger reported on bar close describes the bar that just finished.
One more constraint on the scan timeframe: keep it equal to or higher than the chart timeframe. Requesting a lower timeframe through request.security() returns a single intrabar value for each chart bar, which is rarely what a scanner wants. If you need lower timeframe data, request.security_lower_tf() returns all the intrabar values as an array.
Ranking the results and drawing the table
Collecting results is a matter of pushing each symbol's values into parallel arrays on the last bar, then sorting indexes by score. Parallel arrays are plain and fast, and array.sort_indices() returns the order without moving the data, so the names, prices, and scores stay aligned. Here is the finished scanner, including the budget check, the na guard, the table, and the alert.
//@version=6
indicator("Multi-Symbol Scanner", overlay = true)
string rawList = input.text_area("BINANCE:BTCUSDT, BINANCE:ETHUSDT, BINANCE:SOLUSDT, BINANCE:BNBUSDT, BINANCE:XRPUSDT", "Symbols (comma separated)")
string scanTf = input.timeframe("", "Scan timeframe (empty = chart)")
int fastLen = input.int(20, "Fast EMA", minval = 2)
int slowLen = input.int(50, "Slow EMA", minval = 2)
int rsiLen = input.int(14, "RSI length", minval = 2)
bool alertOn = input.bool(true, "Alert on new fast/slow cross")
scanMetrics(int fLen, int sLen, int rLen) =>
float fast = ta.ema(close, fLen)
float slow = ta.ema(close, sLen)
float rsi = ta.rsi(close, rLen)
bool crossedUp = ta.crossover(fast, slow)
int score = (close > slow ? 1 : 0) + (fast > slow ? 1 : 0) + (rsi > 50 ? 1 : 0)
[close[1], rsi[1], score[1], crossedUp[1]]
var array<string> symbols = array.new<string>()
if barstate.isfirst
for item in str.split(rawList, ",")
string clean = str.trim(item)
if clean != ""
symbols.push(clean)
if symbols.size() > 40
runtime.error("Too many symbols for the request limit. Trim the list.")
var array<string> names = array.new<string>()
var array<float> prices = array.new<float>()
var array<float> rsis = array.new<float>()
var array<int> scores = array.new<int>()
var array<bool> crosses = array.new<bool>()
bool isLast = barstate.islast
if isLast
names.clear()
prices.clear()
rsis.clear()
scores.clear()
crosses.clear()
for sym in symbols
[px, rsiVal, score, crossed] = request.security(sym, scanTf, scanMetrics(fastLen, slowLen, rsiLen), lookahead = barmerge.lookahead_on)
if isLast and not na(rsiVal) and not na(score)
names.push(sym)
prices.push(px)
rsis.push(rsiVal)
scores.push(score)
crosses.push(crossed)
var table board = table.new(position.top_right, 5, 21, border_width = 1)
string hits = ""
if isLast
table.clear(board, 0, 0, 4, 20)
table.cell(board, 0, 0, "Symbol", text_color = color.white, bgcolor = color.gray)
table.cell(board, 1, 0, "Close", text_color = color.white, bgcolor = color.gray)
table.cell(board, 2, 0, "RSI", text_color = color.white, bgcolor = color.gray)
table.cell(board, 3, 0, "Alignment", text_color = color.white, bgcolor = color.gray)
table.cell(board, 4, 0, "Fast/slow", text_color = color.white, bgcolor = color.gray)
array<int> rank = scores.sort_indices(order.descending)
for [r, idx] in rank
if r < 20
int s = scores.get(idx)
table.cell(board, 0, r + 1, names.get(idx))
table.cell(board, 1, r + 1, str.tostring(prices.get(idx), "#.####"))
table.cell(board, 2, r + 1, str.tostring(rsis.get(idx), "#.0"))
table.cell(board, 3, r + 1, str.tostring(s) + " / 3", bgcolor = color.from_gradient(s, 0, 3, color.new(color.red, 70), color.new(color.green, 70)))
table.cell(board, 4, r + 1, crosses.get(idx) ? "crossed up" : "")
for [i, n] in names
if crosses.get(i)
hits += (hits == "" ? "" : ", ") + n
if isLast and alertOn and hits != ""
alert("Fast EMA crossed above slow EMA on the last closed bar: " + hits, alert.freq_once_per_bar)Walk through it in the order it runs. The symbol list is built once on the first bar, with a guard that raises a clear runtime error when the list is longer than the request limit. The parallel arrays are cleared only on the last bar, immediately before they are refilled, so a realtime tick never appends to stale results. The loop requests one tuple per symbol and pushes the values only when the symbol has produced real numbers.
That na guard matters more than it looks. A newly listed symbol, or one with fewer bars than the longest indicator length, returns na for the RSI. Without the guard, those rows reach the sort and the table as NaN, and a sort that mixes valid and na values is hard to reason about. Skipping them keeps the table honest. If you want to see which symbols were skipped, count the difference between symbols.size() and names.size() and print it in a footer row.
The table is capped at twenty rows because the table object has a fixed size when created, and writing outside it is an error. The sort is descending by alignment count, so the symbols with the most conditions met sit at the top. Colors come from color.from_gradient(), which maps the zero to three range onto a red to green background with transparency, so the ranking reads at a glance.
Alerting on a new trigger
The last part of the script is the alert. The alert() function can be called from anywhere in the script, including after a loop, which is why a scanner can use it to report on many symbols with a single alert. The script builds one string containing every symbol whose fast average just crossed above the slow one, and sends it once per bar with alert.freq_once_per_bar.
To use it, add the indicator to a chart, open the alert dialog, choose the indicator as the condition, and select the option for any alert() function call. Because the metrics are read from the previous closed bar, the alert reports a cross that completed on the last closed bar, and the same cross does not fire again on every tick of the current bar. Alert behavior depends on how the alert is created, so test it on a fast timeframe before relying on it. The alerts guide covers the frequency options and the wrong-bar problems that catch people out, and the wrong bar post explains the most common one.
A scanner alert also has a practical limit on size. An alert message carries a bounded amount of text, so a very long list of hits can be cut off. If your list is large, shorten the message to a count and tell yourself to open the table.
Failure modes to check before you trust it
Most scanner problems come from the same short list. The first is a symbol the exchange does not know, either misspelled or without the exchange prefix. TradingView stops the script with an error, and the fix is to correct the entry. The second is hitting the request cap, which produces an error about too many calls. The post on too many securities walks through counting the calls in your script. The third is a table that stays empty, which usually means the symbol list is empty or every symbol returned na.
The fourth is the one worth testing deliberately: a sudden change in results when you change the chart timeframe. If scanTf is empty, the scan follows the chart, so switching the chart from one hour to four hours changes every number in the table. That is correct behavior, but it surprises people. Set the scan timeframe explicitly if you want the table to ignore the chart.
Finally, check the output against an independent source. Pick one symbol, read its RSI from a separate indicator on the same timeframe, and compare it with the table row, remembering that the table shows the previous closed bar. The fetch another symbol guide covers the basics of reading values from other symbols if you want a simpler single symbol check first.
Getting this scanner written with an AI tool
A scanner of this size is where hand-written Pine Script tends to stall. The pieces are each simple, but they interact: a tuple that must match the destructuring on the receiving side, arrays that must stay aligned, a loop that must be safe on an empty list, and an offset that must pair with the lookahead setting. One mismatch and the script will not compile, or compiles and shows wrong numbers.
PineScripter is an AI coding tool for Pine Script that fits this kind of build. You describe the scanner in plain English, naming the metrics, the timeframe, the ranking, and the alert condition, and it writes Pine Script v6. The AI code editor checks the script for compile errors the same way TradingView does and fixes them itself, so you are not copying error messages between a chatbot and the Pine Editor. When you ask for a change, such as adding a volume column or a second condition, it edits only the lines that change, and the Code Changes view shows what moved. The more precisely you describe the scanner, the better the first result, which is the subject of the guide on describing a complex indicator to an AI.
If you send the scanner's alert to a webhook, the alerts tracker can keep a log of each firing with its price and time. That needs a TradingView alert on a plan that supports webhooks. See the pricing page for what each plan includes.
Whether you write the scanner by hand or generate it, the design rules stay the same. Spend one request per symbol, return everything in a tuple, shift the values by one bar and pair that with lookahead, guard against na, loop with for...in, and keep the table small. A scanner built that way is easy to extend with new columns, and it fails loudly instead of quietly showing wrong data. For the broader picture of how tables work, the table dashboard post goes deeper into layout and limits, and the TradingView documentation is the final word on the request limits for your plan.
Disclaimer: PineScripter is a coding tool for Pine Script development. It does not provide financial advice and does not guarantee trading profits. Always backtest strategies thoroughly and understand the risks before live trading.
Describe your indicator in plain English and get Pine Script that compiles →
Disclaimer: PineScripter is a coding tool for Pine Script development. It does not provide financial advice and does not guarantee trading profits. Always backtest strategies thoroughly and understand the risks before live trading.