Strategy Tester
The Strategy Tester runs an OpenScript strategy over the bars a chart has
loaded and reports what it would have made: net P&L after estimated Indian
charges, win rate, profit factor, drawdown, every trade, the equity curve and
saved run records you can compare later. The strategy reads the same fields a
study can, including oi.* and flow.*, and its entries and exits are drawn on
the chart.
Pro and Max plans: on other plans a strategy's plots still draw, but the backtest, its entry and exit markers and the legend P&L do not run, and the chart legend says so.
Writing a strategy
In the Script Editor, declare the script with strategy() instead of
study(). Everything a study can do still works; these calls place orders:
version 1
strategy("EMA cross + delta filter", overlay = true, qty = 1, qtyType = "lots")
fastLength = input(9, "Fast length")
slowLength = input(21, "Slow length")
fast = ema(close, fastLength)
slow = ema(close, slowLength)
flowOk = flow.available == 1 and flow.delta > 0
if crossUp(fast, slow) and flowOk and pos.size == 0
buy()
if crossDown(fast, slow) and pos.size > 0
close()
plot(fast, "Fast EMA", aqua)
plot(slow, "Slow EMA", orange)
| Call | What it does |
|---|---|
buy() / sell() | Open or add to a long / short position of qty. |
close() | Flatten the position. |
exit(...), order.bracket(...) | A stop and a target for the open position, as prices (exit(stop = ..., limit = ...)) or as distances from the entry (exit(loss = ..., profit = ...), order.bracket(profit = ..., loss = ...)). They fill inside the bar; see Stops and targets. |
order.reverse(...), order.place(...) | Stop-and-reverse, and the general form. |
pos.size, pos.avgPrice | The strategy's own open position and its entry price, folded from its fills. |
qty = 1, qtyType = "lots" sizes each order as one lot of the chart symbol's
lot size; qtyType = "units" (the default) trades the stated number of units.
Lots need a symbol that states a lot size, so the example runs on a future or
an option; on an index or equity chart it stops with OS6021 and the legend
says so. Size in units there, or open the contract's own chart.
fillOn = "close" fills a market order (including close()) at the bar it was
decided on instead of the next bar's open; it does not change how stops and
targets fill. Examples ▸ EMA cross strategy in the Script Editor
loads the script above.
Stops and targets
exit() and order.bracket() set a stop and a target that the simulated venue
holds and checks against every bar's high and low, both in the report and in
pos.* on the chart:
if crossUp(fast, slow) and pos.size == 0
buy()
exit(loss = 20, profit = 40)
- Distances are price points from the position's average entry (slippage
included): a long's stop is the entry minus
lossand its target the entry plusprofit, mirrored for a short. They are rounded to the tick against you (down for a long, up for a short) and re-measured after an add.exit(stop = ..., limit = ...)takes absolute prices instead; these must be on the tick (OS7006), so passroundToTick(...). A distance below zero is refused (OS7010), and so is a stop above a long's entry or below a short's, so a stop cannot yet trail past the entry. - The stop fills like a stop order: when the bar trades through it, at the stop, or at the open when the bar gaps past it, worsened by the slippage ticks. The target fills like a limit: only when the bar trades beyond it (touching it is not enough), at the target or a better open, with no slippage.
- One cancels the other. When one bar reaches both, the stop is taken,
unless the bar opened through the target. A bar is four prices with no path,
so the cautious reading is used, and it holds across tags: levels set by
separate
exit()calls are taken in the order the bar reaches them, a level the bar opened through first, then the stops (nearest the open first), then the targets (nearest first). A fill spends the stop and target of its own tag. - Your own resting orders on the way fill first. A limit or stop order of the script's own priced between the bar's open and the first level the bar reaches traded before that level, so it fills first and the levels are measured again: an add re-averages the stop, and a nearer stop of your own that closes the position leaves the level nothing to do.
- Timing. Levels set on a bar protect from the bar the entry fills on: with
the default next-open fill, the entry's own bar. With
fillOn = "close", or an entry filled inside a bar by a limit or stop, they start on the next bar. A level that fills inside a bar is recorded (marker, trade exit,pos.size) on the following bar, like any resting order. A level is tested from the bar after the one that set it, soexit()first called once the entry has filled (underif pos.size > 0, say) leaves the entry's own fill bar unprotected; write it besidebuy()/sell()to cover that bar. - Calling it again replaces the level with the same tag, so calling
exit()on every bar whilepos.sizeis not 0 is fine and does not stack exits. A call for the sameqtyreplaces the levels it names and keeps the ones it leaves out; a call with a differentqtyreplaces the whole stop and target. Writeexit()afterbuy()/sell()on the same bar (and, to move it, again on later bars): anexit()while flat, or written before the entry, protects nothing.exit()besideorder.reverse()protects the new side. exit(qty = ...)closes part of the position (never more than it holds) and is then spent for that position, even if re-sent; give anotherexit()its own tag to guard the rest.order.bracket()always closes the whole position.- Levels go with the position.
close(),order.reverse()or an opposite entry that flattens or turns the position over cancels its stop and target, and a level never opens a position. Once a level has closed an entry,close(tag)for that entry's tag sends nothing.cancel(tag)removes a level only when a working order also carries that tag; on its own it stops withOS7009. - Exits with
close()are different: the script decides them on a completed bar's close and they fill at the next bar's open (or that close withfillOn = "close").
Running it
- Save the script, then open a chart and add it from Indicators ▸ My scripts. The chart draws its plots and marks every fill: buys (long entries and short exits) as up arrows below the bar, sells (short entries and long exits) as down arrows above it. The legend shows closed trades and net after charges.
- Add the Strategy Tester panel. It lists every strategy applied to a chart in the workspace (script · symbol · interval); pick one from the dropdown.
- The run refreshes by itself whenever the chart's completed bars change: a new bar, more history scrolled in, a different interval, or an edited input.
The test uses completed bars only. The forming bar is excluded so no order
sees a close that has not happened yet, and ticks on it redraw the plots without
re-running the test. Market orders decided on a bar, close() included, fill
at the next bar's open (or the bar's close with fillOn = "close"), worsened by
the slippage ticks in the charge schedule. Stops and targets fill inside a later
bar at their level, as above. A test is bounded by what the chart has loaded,
at most 50,000 bars.
The tabs
| Tab | What it shows |
|---|---|
| Overview | Net P&L after charges with gross and charges, win rate, profit factor, max drawdown (money, percent of peak, longest in bars), trade counts and bars in market, expectancy with its standard error, long versus short, largest win and loss with streaks, and a month-by-month table (UTC months). |
| Trades | One row per round trip: side, entry and exit time and price, units, bars held, gross, charges, net, best and worst excursion marked to bar closes (MFE / MAE). Click a row to scroll the chart to the entry bar. Open trades are marked. |
| Equity | Equity marked to each bar's close, open profit included, and the drawdown from the running peak. |
| Runs | Run records saved for this script in this browser (up to 20 per script): save the current run, export any record as JSON, delete one. The Same as current badge compares each record's bars hash, program hash, inputs and charge schedule with the run on the chart now; "identical inputs" means re-running reproduces the record exactly. |
| Costs | The charge schedule, editable per profile. |
Estimated charges
Each fill is costed as one order under the profile for the chart symbol's
exchange and instrument (NSE, BSE or MCX futures and options, equity intraday):
brokerage per order, STT or CTT on the sell side, exchange transaction charge
and SEBI fee on turnover, stamp duty on the buy side, GST on brokerage +
exchange + SEBI, and slippage in ticks of the chart's tick-size setting on
market and stop fills (limit fills and targets fill at their price). The
defaults are published 2025-26 rates and are estimates, not tax advice:
edit them in the Costs tab and every open chart re-runs; the schedule travels
with each saved run record. A script that declares its own commission keeps
it, and the schedule is not applied: the script's own slippage is used too,
in the report and in pos.* on the chart.
What the data is
- Bars are the chart's own: one-second Level-1 rows aggregated per interval.
flow.delta,flow.cvdand the trade counts come from the backend's inferred side on each one-second row, not an exchange aggressor flag. oi.*is a level re-sent on traded rows; an untraded contract, an index or an equity carries none.- Fills are bar prices: market and stop fills plus tick slippage, limit fills
and targets at their price. There is no depth, no partial fill and no
liquidity model. Bars are OHLC only, so the order of prices inside a bar is
unknown: a stop or target is never tested on the bar its entry filled inside,
and the stop wins when one bar reaches both a stop and a target, whichever
exit()set them. - The report and its markers re-run on every new completed bar.
pos.*drawn on the chart is computed incrementally on live bars, and there stops, targets and other resting orders are only checked against bars the script had when it last ran in full (applied, edited or reloaded). - Expired contracts and rollover-adjusted series are not available on the chart, so a test covers one live contract's loaded history.
- Run records are kept in this browser only; export the JSON to keep one.
Forward-testing a strategy on the paper account is not available yet.