The Open-Source TradingView Optimizer
The open-source TradingView optimizer grid searches strategy inputs and ranks the winner. Here is the overstatement it never prints, worked on real data.
The open-source TradingView optimizer is a Chrome extension that drives TradingView's own Strategy Tester: it steps through combinations of a strategy's inputs, records what the Strategy Tester reports for each one, and ranks those combinations by a goal you choose. It saves hours of clicking through a settings dialog. It also hands back one number with no warning label attached, the winning combination's score, which is the maximum of many noisy estimates and is overstated for that reason alone. This post covers the method, then works through the arithmetic the tool leaves out.
What the open-source TradingView optimizer actually does
The extension does not reimplement a backtest. It reads the inputs a Pine strategy exposes, numeric fields, dropdown menus and checkboxes, sets each one, waits for the Strategy Tester to finish recalculating, and scrapes the result. Every figure in its output comes back from TradingView, which is what makes it trustworthy relative to a home-made engine and also what binds it to whatever the Strategy Tester already assumes about fills, commission and bar-close execution. If you have never traced how those assumptions enter a result, what a backtest is walks through the mechanics first.
Version 3.0.0, the release read for this post in September 2026, carried 54 stars, 12 forks and 7 commits on its public repository. That is an early-stage project, so the method below is the durable part.
The search runs in four modes:
- Standard sweeps the inputs on one symbol and one timeframe.
- Multi-Timeframe repeats the sweep across several chart timeframes.
- Multi-Symbol repeats it across a list of symbols.
- Full Grid runs every combination of every enabled input in one pass.
Each run ranks the finished cells by a goal, Sharpe ratio or profit factor among them, and returns a sortable table, 2D and 3D heatmaps of the parameter surface, a JSON export, and a local history of the last ten runs. The heatmaps are the most useful output on the page, for reasons the plateau section below sets out.
The project's documentation states its limit plainly:
Backtest and optimization results do not guarantee future performance.
That is accurate, and it is also the weaker half of the story. The sharper problem is not that the future differs from the past. It is that the winning cell misdescribes the past it was fitted to.
Why a grid search inflates the winning Sharpe
Every backtested Sharpe ratio is an estimate taken from a finite sample of days. Write each cell's measured score as the strategy's true long-run edge plus a sampling error that is specific to those days. Rank the cells by their measured scores and you rank them partly on the error term. The cell that lands on top is the one whose sampling error happened to be the largest positive number in the grid, so the top score carries that error along with whatever edge is real. The more cells you run, the larger that maximum error tends to be.
The grid below makes the spread visible. Six fast moving-average lengths crossed with four slow ones, 24 cells, long SPY on days when the fast average sits above the slow one and flat otherwise, measured on daily bars from January 2016 through June 2025. Each day's position is decided from data through that day's close and earns the following day's return, which keeps look-ahead bias out of the comparison.
| cell | sharpe |
|---|---|
| 5 / 200 | 0.93 |
| 5 / 150 | 0.88 |
| 10 / 200 | 0.84 |
| 10 / 100 | 0.83 |
| 15 / 200 | 0.82 |
| 25 / 200 | 0.78 |
| 15 / 100 | 0.78 |
| 20 / 200 | 0.74 |
| 10 / 150 | 0.74 |
| 15 / 150 | 0.73 |
| 30 / 200 | 0.71 |
| 5 / 100 | 0.7 |
| 10 / 50 | 0.7 |
| 5 / 50 | 0.68 |
| 20 / 150 | 0.66 |
| 30 / 150 | 0.66 |
| 30 / 50 | 0.65 |
| 25 / 150 | 0.64 |
| 20 / 100 | 0.63 |
| 15 / 50 | 0.61 |
The exact SQL behind every number
WITH
series AS
(
SELECT arraySort(r -> r.1, groupArray((date, toFloat64(close)))) AS rows_sorted
FROM global_markets.stocks_daily_aggs
WHERE ticker = 'SPY'
AND date >= '2016-01-04'
AND date <= '2025-06-30'
),
grid AS
(
SELECT
arrayMap(r -> r.2, rows_sorted) AS px,
g.1 AS fast,
g.2 AS slow
FROM series
ARRAY JOIN
[
(5, 50), (5, 100), (5, 150), (5, 200),
(10, 50), (10, 100), (10, 150), (10, 200),
(15, 50), (15, 100), (15, 150), (15, 200),
(20, 50), (20, 100), (20, 150), (20, 200),
(25, 50), (25, 100), (25, 150), (25, 200),
(30, 50), (30, 100), (30, 150), (30, 200)
] AS g
),
cells AS
(
SELECT
fast,
slow,
arrayMap(
i -> if(arrayAvg(arraySlice(px, i - fast + 1, fast)) > arrayAvg(arraySlice(px, i - slow + 1, slow)),
px[i + 1] / px[i] - 1,
0.0),
range(200, length(px))
) AS rets
FROM grid
)
SELECT
concat(toString(fast), ' / ', toString(slow)) AS cell,
round(arrayAvg(rets)
/ sqrt(arrayAvg(arrayMap(r -> r * r, rets)) - pow(arrayAvg(rets), 2))
* sqrt(252), 2) AS sharpe
FROM cells
ORDER BY sharpe DESCAcross 24 cells, the top-ranked pair, 5 / 200, measured a Sharpe of 0.93. The bottom-ranked pair measured 0.46. A reader shown only the winner sees a tidy result. A reader shown the whole bar chart sees a distribution, and the winner is its right edge.
How large is the overstatement on a realistic grid?
Start with the error bar on a single cell. When the true Sharpe is near zero, the standard error of a Sharpe measured over Y years of returns is roughly 1 divided by the square root of Y. The window above runs about nine and a half years, giving a standard error near 0.32. One cell with no real edge at all lands somewhere around plus or minus 0.32 in a typical run, and further out one time in three.
Order statistics supply the rest. The expected maximum of N independent standard normal draws is about 1.5 at N of 10, about 2.0 at N of 25, about 2.5 at N of 100 and about 3.0 at N of 500. Multiply those by the 0.32 standard error. A 25-cell grid over a rule with no edge whatsoever is expected to return a best cell near 0.63. A 500-cell grid returns one near 0.96. A Full Grid over four numeric inputs with ten steps each is 10,000 cells, and its best cell clears 1.0 on noise alone.
Two corrections, both real. Grid cells are not independent: a 10 over 100 crossover and a 15 over 100 crossover trade nearly the same days, so the effective number of independent draws is far below the cell count, which pulls the inflation down. Pushing the other way, a published strategy has been selected twice, once inside the grid and once across all the ideas that were tried before this one, and that second selection appears in no cell count anywhere.
The standard error is not an abstraction either. Here is one unchanged rule, buy and hold SPY, scored separately in each complete calendar year.
| year | spy_sharpe |
|---|---|
| 2016 | 0.77 |
| 2017 | 2.67 |
| 2018 | -0.3 |
| 2019 | 2.08 |
| 2020 | 0.62 |
| 2021 | 1.9 |
| 2022 | -0.78 |
| 2023 | 1.73 |
| 2024 | 1.73 |
The exact SQL behind every number
WITH daily AS
(
SELECT
d,
px / prev_px - 1 AS ret
FROM
(
SELECT
date AS d,
toFloat64(close) AS px,
lagInFrame(toFloat64(close)) OVER (ORDER BY date ASC ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS prev_px
FROM global_markets.stocks_daily_aggs
WHERE ticker = 'SPY'
AND date >= '2015-12-31'
AND date <= '2024-12-31'
)
WHERE prev_px > 0
)
SELECT
toString(toYear(d)) AS year,
round(avg(ret) / stddevPop(ret) * sqrt(252), 2) AS spy_sharpe
FROM daily
GROUP BY year
ORDER BY yearThe rule scored 0.77 in 2016, -0.78 in 2022, and 1.73 in 2024, across 9 complete years. Nothing about the strategy changed over that span. Only the sample did. A gap of a few tenths of a point between two cells of a grid lives comfortably inside that range.
Reading the parameter heatmap: plateau or spike
This is where the tool's 2D and 3D heatmaps earn their place. An isolated spike is a cell that scores well while its immediate neighbours score poorly. A plateau is a block of adjacent cells that all score similarly. The panel below slices the same grid the other way: the fast length on the x-axis, one line per slow length, three of the four shown for legibility.
| fast_ma | sharpe_slow_50 | sharpe_slow_100 | sharpe_slow_200 |
|---|---|---|---|
| 5 | 0.68 | 0.7 | 0.93 |
| 10 | 0.7 | 0.83 | 0.84 |
| 15 | 0.61 | 0.78 | 0.82 |
| 20 | 0.61 | 0.63 | 0.74 |
| 25 | 0.57 | 0.46 | 0.78 |
| 30 | 0.65 | 0.6 | 0.71 |
The exact SQL behind every number
WITH
series AS
(
SELECT arraySort(r -> r.1, groupArray((date, toFloat64(close)))) AS rows_sorted
FROM global_markets.stocks_daily_aggs
WHERE ticker = 'SPY'
AND date >= '2016-01-04'
AND date <= '2025-06-30'
),
grid AS
(
SELECT
arrayMap(r -> r.2, rows_sorted) AS px,
g.1 AS fast,
g.2 AS slow
FROM series
ARRAY JOIN
[
(5, 50), (5, 100), (5, 150), (5, 200),
(10, 50), (10, 100), (10, 150), (10, 200),
(15, 50), (15, 100), (15, 150), (15, 200),
(20, 50), (20, 100), (20, 150), (20, 200),
(25, 50), (25, 100), (25, 150), (25, 200),
(30, 50), (30, 100), (30, 150), (30, 200)
] AS g
),
scored AS
(
SELECT
fast,
slow,
arrayAvg(rets)
/ sqrt(arrayAvg(arrayMap(r -> r * r, rets)) - pow(arrayAvg(rets), 2))
* sqrt(252) AS sharpe
FROM
(
SELECT
fast,
slow,
arrayMap(
i -> if(arrayAvg(arraySlice(px, i - fast + 1, fast)) > arrayAvg(arraySlice(px, i - slow + 1, slow)),
px[i + 1] / px[i] - 1,
0.0),
range(200, length(px))
) AS rets
FROM grid
)
)
SELECT
fast AS fast_ma,
round(maxIf(sharpe, slow = 50), 2) AS sharpe_slow_50,
round(maxIf(sharpe, slow = 100), 2) AS sharpe_slow_100,
round(maxIf(sharpe, slow = 200), 2) AS sharpe_slow_200
FROM scored
GROUP BY fast
HAVING countIf(slow = 50) > 0
AND countIf(slow = 100) > 0
AND countIf(slow = 200) > 0
ORDER BY fast_maAgainst a 200-day slow average, the measured Sharpe runs from 0.93 at a 5-day fast average to 0.71 at 30 days, while the 50-day column opens at 0.68. Adjacent cells share most of their trades, so a flat stretch means the result survives the input being off by a step or two. A lone spike means it does not, and a lone spike is the shape that pure sampling noise produces most readily. Given two cells with the same score, the one sitting in the middle of a flat region is the more repeatable of the two.
What a walk-forward pass fixes, and what it does not
A walk-forward pass splits the history in two, optimizes on the earlier segment only, then scores the single chosen winner on the later segment, which the search never saw. The panel below does exactly that with the same 24 cells: optimize on 2016 through 2020, then measure every cell on 2021 through mid-2025, and keep the in-sample ranking on the x-axis.
| in_sample_rank | cell | in_sample_sharpe | out_of_sample_sharpe |
|---|---|---|---|
| 1 | 30 / 50 | 1.05 | 0.27 |
| 2 | 25 / 50 | 1.02 | 0.16 |
| 3 | 5 / 200 | 1.01 | 0.85 |
| 4 | 20 / 50 | 0.98 | 0.25 |
| 5 | 5 / 150 | 0.98 | 0.78 |
| 6 | 10 / 100 | 0.96 | 0.71 |
| 7 | 15 / 50 | 0.9 | 0.34 |
| 8 | 15 / 100 | 0.87 | 0.69 |
| 9 | 10 / 200 | 0.84 | 0.84 |
| 10 | 10 / 150 | 0.81 | 0.67 |
| 11 | 5 / 50 | 0.75 | 0.62 |
| 12 | 15 / 150 | 0.71 | 0.76 |
| 13 | 15 / 200 | 0.69 | 0.97 |
| 14 | 25 / 200 | 0.66 | 0.95 |
| 15 | 5 / 100 | 0.66 | 0.75 |
| 16 | 20 / 100 | 0.65 | 0.61 |
| 17 | 10 / 50 | 0.56 | 0.82 |
| 18 | 30 / 150 | 0.51 | 0.88 |
| 19 | 20 / 150 | 0.48 | 0.88 |
| 20 | 30 / 200 | 0.48 | 1.03 |
The exact SQL behind every number
WITH
series AS
(
SELECT arraySort(r -> r.1, groupArray((date, toFloat64(close)))) AS rows_sorted
FROM global_markets.stocks_daily_aggs
WHERE ticker = 'SPY'
AND date >= '2016-01-04'
AND date <= '2025-06-30'
),
grid AS
(
SELECT
arrayMap(r -> r.2, rows_sorted) AS px,
length(arrayFilter(r -> r.1 < toDate('2021-01-01'), rows_sorted)) AS split_at,
g.1 AS fast,
g.2 AS slow
FROM series
ARRAY JOIN
[
(5, 50), (5, 100), (5, 150), (5, 200),
(10, 50), (10, 100), (10, 150), (10, 200),
(15, 50), (15, 100), (15, 150), (15, 200),
(20, 50), (20, 100), (20, 150), (20, 200),
(25, 50), (25, 100), (25, 150), (25, 200),
(30, 50), (30, 100), (30, 150), (30, 200)
] AS g
),
cells AS
(
SELECT
fast,
slow,
arrayMap(
i -> if(arrayAvg(arraySlice(px, i - fast + 1, fast)) > arrayAvg(arraySlice(px, i - slow + 1, slow)),
px[i + 1] / px[i] - 1,
0.0),
range(200, split_at)
) AS is_rets,
arrayMap(
i -> if(arrayAvg(arraySlice(px, i - fast + 1, fast)) > arrayAvg(arraySlice(px, i - slow + 1, slow)),
px[i + 1] / px[i] - 1,
0.0),
range(split_at, length(px))
) AS oos_rets
FROM grid
),
scored AS
(
SELECT
concat(toString(fast), ' / ', toString(slow)) AS cell,
round(arrayAvg(is_rets)
/ sqrt(arrayAvg(arrayMap(r -> r * r, is_rets)) - pow(arrayAvg(is_rets), 2))
* sqrt(252), 2) AS in_sample_sharpe,
round(arrayAvg(oos_rets)
/ sqrt(arrayAvg(arrayMap(r -> r * r, oos_rets)) - pow(arrayAvg(oos_rets), 2))
* sqrt(252), 2) AS out_of_sample_sharpe
FROM cells
)
SELECT
row_number() OVER (ORDER BY in_sample_sharpe DESC) AS in_sample_rank,
cell,
in_sample_sharpe,
out_of_sample_sharpe
FROM scored
ORDER BY in_sample_rankThe cell an optimizer would have handed you in January 2021, 30 / 50, measured 1.05 over the training window and 0.27 over the four and a half years that followed. The cell ranked last in training, 25 / 100, measured 0.61 out of sample. Read the two numeric columns against each other across all 24 rows: the left column is what the ranking was built on, the right column is what a live trader would have received, and the ordering of one carries only loosely into the other.
What the split fixes is specific and worth having. The number reported on the held-out window is a single honest draw rather than a maximum, so it is unbiased for the cell that was chosen. What it leaves untouched is longer:
- Re-running the search after seeing the held-out result spends it. Every extra look turns the test window into another selection window.
- It returns a point estimate and no error bar. The held-out Sharpe carries the same 1 over square-root-of-Y noise as any other, over a shorter window and so with more of it.
- The structural choices, which symbol, which timeframe, which family of rules, were all made with the full history in view, and no in-sample split touches them.
- If the symbol list came from today's index membership, survivorship bias sits underneath every cell in the grid, winner included.
For an error bar rather than a point, resample the winner's trade sequence and read the interval: bootstrapped confidence intervals covers the procedure, and the interval on a grid winner is usually wide enough to end the argument. The broader checklist, walk-forward windows included, lives in how to backtest a trading strategy.
FAQ
What is the open-source TradingView optimizer?
It is a Chrome extension that automates TradingView's Strategy Tester. It sets each combination of a strategy's numeric, dropdown and checkbox inputs, collects the result TradingView reports, and ranks the combinations by a chosen goal such as Sharpe ratio or profit factor, with heatmaps and a JSON export of the run.
Why does the best result in a parameter sweep overstate a strategy?
The reported winner is the maximum of N noisy estimates, and a maximum is biased upward by construction. With a Sharpe standard error near 0.32 on a decade of daily data, a 500-cell grid over a rule with no real edge still returns a best cell near 0.96.
Does walk-forward testing fix overfitting?
It fixes one part. The score of the chosen cell on a genuinely untouched window is an unbiased single draw rather than a maximum. It does not fix repeated reuse of that window, structural choices made with full hindsight, or the absence of an error bar around the result.
Is a heatmap plateau better than a spike?
A plateau means neighbouring parameter values score similarly, so the result tolerates the input being off by a step. An isolated spike is the pattern that random sampling error produces most often, and it disappears on the next sample more readily than a flat region does.
How many parameter combinations are too many?
There is no fixed cap, though the selection effect grows with the count, so report the grid size next to the winner's score. A Full Grid of four inputs with ten steps each is 10,000 cells, and a winner drawn from that many deserves an out-of-sample score and a confidence interval before anything else.
Every panel above ships with the SQL that produced it, expandable underneath. To run the same grid over a different symbol or a different window, ask for it in plain English on the Strasmore terminal.