Reference
Strategy code
The TypeScript a strategy is written in on AnalyticsFX: what is in scope, how a series is read, and what an order can say.
What a strategy file is
A strategy is a module body, not a module. Your source is transpiled from TypeScript to JavaScript and compiled into a function whose parameters are the only names it can see.
new Function(...names, `"use strict";
let config = {};
let onBar = null;
<your source>
return { config, onBar };`)- A strategy exports nothing, and the only imports are your own library modules (see Library modules). It says what it is by assigning to config and onBar, which the runtime has already declared.
- The only names in scope are the ones the runtime passes in. globalThis, window, document, fetch, require, process and import are not among them. The interpreter's own built-ins are: Math, JSON, Date, Number, Array, Object, String.
- The body runs in strict mode, once, before any bar. An assignment to an undeclared name throws instead of creating a global.
The body is real code: loops, your own functions, state carried between bars, and types. Types are stripped before it runs, and the transpiler blanks what it removes rather than reflowing the file, so an error on line 14 is still line 14 of what you wrote.
config = { symbol: "EURUSD", interval: "1H", balance: 10000, leverage: 30 };
// Indicators once, at the top. Each is a whole series, computed once.
const trend = ema(close, 50);
onBar = (ctx) => {
if (!trend.ready) return;
if (ctx.flat && close.now > trend.now) ctx.buy({ lots: 0.5, stop: 0.004 });
if (ctx.long && close.now < trend.now) ctx.close();
};A strategy that never assigns onBar is refused (a file that only draws is an indicator, which may leave onBar out: see Indicators). So is a type annotation on config or onBar: they are assigned, not declared, so config: Config = { ... } parses as a label followed by an assignment to Config. Write config = { ... } and annotate your own const declarations instead.
While you write it, a strategy runs in the browser inside a Worker, so a loop that never ends can be killed rather than taking the tab. The worker deadline is 10 seconds, and inside the run there is a further budget of 5 seconds checked between bars. Once it is live it runs on the server inside QuickJS compiled to WebAssembly, with a 5 second deadline and a 32 MB heap, and the two interpreters are tested to give the same answer.
config
Every field is optional to the runtime, but symbol and interval are required to run: without them the run stops before any bars are fetched. Pips and fractions are the two easy mistakes. spread: 0.8 is eight tenths of a pip, and maxDrawdown: 0.3 is thirty percent.
| Field | Units and meaning |
|---|---|
| name | A label. Used as the strategy's name when it goes live. |
| symbol | The instrument as the platform names it, such as EURUSD. Required. |
| interval | The bar size, such as 1H or 1D. Required. |
| balance | Starting balance in account money. Defaults to 10,000. |
| leverage | Defaults to 100. |
| spread | Pips. Multiplied by the symbol's pip size before it reaches the engine. |
| slippage | Pips. |
| commission | Account money per lot, round turn, as a number or { perLot: 7 }. Or { perOrder: 2 }: money per fill, the entry and every exit (a partial exit is one). Or { percent: 0.05 }: a percent of each fill's value, entry plus exit, converted to account money the way the run converts profit. Every form is booked when the position closes. A forward account charges its own commission setting instead (none unless the account sets one): see Commission. |
| swapLong, swapShort | Account money per lot per night. Negative is a cost. |
| maxTrades | Stop opening once this many positions have been opened in the run. |
| minHold | Bars. A position may not be exited or reversed until it has been held this long. |
| maxDrawdown | A fraction. Measured on marked equity against its peak; the run halts when it is reached. |
| closeBeforeWeekend | Flatten before the weekend gap. |
| challenge | { dailyLoss, maxLoss, profitTarget }, all fractions. Judged after the run. |
| risk | { maxDailyLoss, maxConsecutiveLosses, maxLots, dayTz, allow }: limits that stop the strategy opening positions while it runs. maxDailyLoss is a fraction of the equity the day started with (open profit counts, marked at bar closes); maxConsecutiveLosses is losing positions in a row. Either stops new entries for the rest of that day, and an entry order still waiting is withdrawn. maxLots refuses an entry sized above it at the fill; it is never made smaller. allow: "long" or "short" refuses the other side, and a reversal into it only closes. dayTz is the time zone whose midnight starts a day, "UTC" unless set; a decision belongs to the day its bar closes in. A close, a reduce or a modify always goes through. The limits act on the strategy's own account: live runs replay the same bars, so they refuse the same entries. In forward mode the real forward account is also checked against maxDailyLoss and maxConsecutiveLosses. The debugger names the limit that refused. See Risk limits. |
| fill | "nextOpen" (the default) or "close": when what a bar decides fills. "close" is optimistic and one market only. See Filling at the close. |
| optimise | What Optimise and Walk-forward rank by: "net" (the default), "sharpe", "profitFactor", "maxDrawdown" (lower is better), "winRate" or "returnDrawdown", or { metric: "name", direction: "max" | "min", stat: "last" } for a metric() of your own. See Inputs and optimising. |
| magnify | true to replay a bar that reached both a stop and a profit level through its finer bars (5M under 1H, 1H under 1D) instead of assuming the stop was hit first. Only those bars are loaded, for a portfolio's markets and across a sweep too. |
Series
Every price and every indicator is a series: one reading per bar, not a number. There are four ways to read one, and no others.
| Member | Type | Meaning |
|---|---|---|
| .now | number | null | The reading at the bar being decided. null where there is none yet. |
| .ago(k) | (bars: number) => number | null | The reading k bars before this one. 0 is now. |
| .ready | boolean | True once the series has a reading at this bar. |
| .length | number | How many bars the series covers. |
A negative argument does not return null. It throws:
close.ago(-1) asks for a bar that has not happened yet. A strategy can only read the past; that is what makes its backtest mean anything.
The guarantee is structural, not a convention. A strategy is never handed an array of bars. It is handed handles whose values live in a module-private WeakMap that nothing outside that one file can reach, and the only ways to get a number out are now and ago(k) with k at or above zero. The cursor that says which bar is being decided is moved forward one bar at a time by the runner. In the server sandbox it is stronger still: the guest holds an integer id and the values never cross into it at all.
Declare indicators once, at the top. Each is the whole series computed once over all the bars and memoised, so reading it is what happens per bar. An indicator has no reading on its first bars, and 2 * atr(14).now is then 2 * null, which JavaScript makes 0 and an order refuses. Guard the bar with if (!vol.ready) return;
Everything in scope
| Series | Meaning |
|---|---|
| open, high, low, close | The bar's four prices. |
| volume | Traded volume on crypto; the feed's tick count on FX, metals and indices. Null on a bar with none. |
| hour | The hour of the bar's open time in UTC, 0 to 23. |
| minute | The minute of the bar's open time in UTC, 0 to 59. |
| weekday | ISO weekday of the bar's open in UTC: Monday 1, Sunday 7. |
| month | 1 to 12. |
| monthday | 1 to 31. |
| tokyo, london, newyork | 1 while that session is open, 0 otherwise. |
| timeClose | Each bar's close time in epoch milliseconds: its open time plus the timeframe. See Bar state and time. |
volume is the one series that can fail: on a market with no volume on any bar the first read throws and names the market, rather than handing back a series of nothing that would make a volume filter block every trade. On FX, metals and indices it is a TICK COUNT, the number of price updates the feed saw, because there is no central exchange to report traded size: a fair measure of activity that does not compare across symbols. Crypto carries real traded volume. There is no spread series, because no bar table stores one; a backtest charges the spread you set in config.
Every indicator, with the signature the runtime generates. All of them return a series.
sma(series, length) ema(series, length)
rma(series, length) wma(series, length)
hma(series, length) kama(series, length)
stdev(series, length) sum(series, length)
highest(series, length) lowest(series, length)
rsi(series, length) zscore(series, length)
percent_rank(series, length) ref(series, barsBack)
change(series, bars)
atr(length) adx(length)
true_range() cci(length)
stoch(length) stoch_d(length, smooth)
donchian_mid(length)
vwap(anchor) vwma(series, length)
obv() mfi(length)
linreg(series, length, offset) correlation(a, b, length)
dmi_plus(length) dmi_minus(length)
roc(series, length) sar(start, increment, max)
valuewhen(condition, series, occurrence)
rising(series, length) falling(series, length)
highestbars(series, length) lowestbars(series, length)
median(series, length)
alma(series, length, offset, sigma) tsi(series, short, long)
wpr(length) aroon_up(length) aroon_down(length)
ha_open() ha_high() ha_low() ha_close()
zigzag_pivot(percent) zigzag_ago(percent)
zigzag_dir(percent) zigzag_extreme(percent)
macd(series, fast, slow)
macd_signal(series, fast, slow, signal)
macd_hist(series, fast, slow, signal)
bb_upper(series, length, mult) bb_lower(series, length, mult)
kc_upper(length, mult) kc_lower(length, mult)
supertrend(atrLength, factor) supertrend_dir(atrLength, factor)
pivot_high(series, left, right) pivot_low(series, left, right)
anchored_avg(series, anchor) latch(set, reset)
bars_since(condition)
crossAbove(a, b) crossBelow(a, b)
abs(x) sign(x) sqrt(x) log(x) tanh(x) min(a, b) max(a, b)What the newer indicators mean, and the studies that answer several lines at once (ichimoku, pivotPoints, heikinAshi, zigzag, fractals, aroon), are in More indicators. Currency rates and the bars inside a bar are in Currency rates and lower timeframes, and your own saved indicators in Saved indicators.
The zigzag also has an ATR mode: zigzag_atr_pivot, zigzag_atr_ago, zigzag_atr_dir and zigzag_atr_extreme(length, mult) confirm a turn when price comes back mult times atr(length) from the leg's extreme, and a script writes zigzag({ atr: 14, mult: 3 }). As with the percent mode, the last leg repaints on a chart and no value reads ahead.
crossAbove and crossBelow exist because crossing is an operator in the expression language and has no JavaScript spelling. They return a series like anything else, so crossAbove(fast, slow).now reads the same way as fast.now. A name that does not exist gives JavaScript's own message with the real list appended.
The calendar and the policy rate are series like any other, and this is the part that is easy to miss.
| Call | What it reads |
|---|---|
| surprise(currency?) | The standardised surprise of the latest release as a z-score, faded with a five day half-life. Positive means the data beat the forecast. |
| surpriseHigh(currency?) | The same, over high-impact releases only. Also spelled surprise_high. |
| released(currency?) | 1 on a bar where a release landed since the previous bar opened, 0 otherwise. |
| rate(currency?) | The policy rate in force, in percent. |
| rateDiff(base?, quote?) | Base less quote, in percentage points. Also spelled rate_diff. |
The currency is a STRING here, not a bare word: surprise("USD"), not surprise(USD). With no argument the pair's own quote currency is used, so surprise() on EURUSD, XAUUSD and BTCUSD all read the dollar calendar; on a symbol whose quote is not a calendar currency the call refuses rather than guessing. The eight covered currencies are USD, EUR, GBP, JPY, CHF, AUD, NZD and CAD, for both the calendar and the policy rate.
surprise() keeps its value for days as it fades, so it is not an event gate on its own. released() is the gate. Both obey the same strictly-before rule as everything else: a bar may read a release only when it landed before that bar's open. There is no way to read the actual or the forecast separately, and no way to name a single event.
Government bond yields are series too, read from the platform's own store of official daily closes.
| Call | What it reads |
|---|---|
| yieldOf(currency?, tenorYears?) | A government bond yield in percent: yieldOf("USD", 2) is the US 2 year. The quote currency and 10 years when left out. Also spelled yield_of. |
| yieldSpread(base?, quote?, tenorYears?) | Base yield less quote yield, in percentage points: yieldSpread(2) on EURUSD is the German 2 year less the US 2 year. Also spelled yield_spread. |
| macro(id) | Any stored series by id, e.g. macro("US10Y"). |
| macroDiff(a, b) | The first series less the second, on the dates both have: macroDiff("US10Y", "US2Y") is the US curve slope. Also spelled macro_diff. |
Ids are the country and the tenor: US3M, US2Y, US5Y, US10Y and US30Y (US Treasury constant maturities), DE2Y, DE5Y, DE10Y and DE30Y (German Bunds, the Bundesbank's fitted curve; EUR means Germany, not a euro area average), GB2Y (the Bank of England's fitted nominal spot curve), GB5Y, GB10Y and GB20Y (UK gilt par yields from the Bank's database: close to the spot curve, not the same measure), JP2Y, JP5Y, JP10Y and JP30Y (JGBs), CA2Y, CA5Y and CA10Y (Government of Canada benchmarks) and CH2Y, CH5Y, CH10Y and CH30Y (Swiss Confederation spot yields). There are no Australian or New Zealand yields. History goes back to 1962 for the US, 1974 for Japan, 1979 for the UK 2 year, 1988 for Switzerland, 1993 for the other UK tenors, 1997 for Germany and 2001 for Canada. A tenor a country lacks fails at the call, naming the tenors it has. Sources: Board of Governors of the Federal Reserve System (H.15) via FRED, Deutsche Bundesbank, Bank of England, Ministry of Finance Japan, Bank of Canada and Swiss National Bank.
A yield is a daily close, so it is readable from 00:00 UTC on the next day and never on its own day: on 1H bars every bar of a day reads the previous day's close, and a daily bar that opens at midnight reads the close of the day before it. Some publish later and are read later: the UK and German yields from 12:00 UTC on the next business day, when the Bank of England and the Bundesbank post them (so a Friday close is read on Monday), and the Swiss yields only from the 8th of the following month, because the SNB publishes a month of closes at once (so on a live chart the latest CHF yield is up to five weeks old). A side that issues no bonds (XAU, BTC) is left out with the pair's sign kept, so yieldSpread(2) on XAUUSD is minus the US 2 year. Before a series' first stored day it reads null, never 0. Write the ids, currencies and tenor out, as for surprise(): they are what gets loaded before the run, in the editor and live.
config = { symbol: "EURUSD", interval: "1D", balance: 10000, leverage: 30 };
// The German less the US 2 year spread, and how far it moved over a trading week.
const spread = yieldSpread("EUR", "USD", 2);
const moved = sub(spread, ref(spread, 5));
onBar = (ctx) => {
if (moved.now === null) return;
if (ctx.flat && moved.now > 0.15) ctx.buy({ lots: 0.5, stop: 0.01 });
if (ctx.long && moved.now < 0) ctx.close();
};The same store holds the rest of the platform's macro data. Every series is read with macro("ID"), and macroDiff("A", "B") subtracts one from another on the dates both have.
| Ids | What | Unit and frequency | History from | Source |
|---|---|---|---|---|
| US5Y_REAL, US10Y_REAL | US TIPS real yields | percent, daily | 2003 | Federal Reserve Board (H.15), via FRED |
| US_BE5Y, US_BE10Y | US breakeven inflation (nominal less real yield) | percent, daily | 2003 | Federal Reserve Bank of St. Louis, via FRED |
| VIX | Cboe Volatility Index close | index points, daily | 1990 | Chicago Board Options Exchange, via FRED |
| SOFR, ESTR | US and euro area overnight rates | percent, daily | 2018, 2019 | Federal Reserve Bank of New York via FRED; ECB |
| USD_BROAD | Fed nominal broad dollar index (H.10), not ICE's DXY | index, daily values published weekly | 2006 | Federal Reserve Board (H.10), via FRED |
| OIL_WTI_SPOT, OIL_BRENT_SPOT | Crude oil spot prices (not the chart's WTI and BRENT markets) | USD per barrel, daily | 1986, 1987 | U.S. Energy Information Administration, via FRED |
| US_CPI_YOY, EU_HICP_YOY, UK_CPI_YOY, JP_CPI_YOY, CH_CPI_YOY, CA_CPI_YOY, AU_CPI_YOY, NZ_CPI_YOY | CPI, change on a year earlier | percent, monthly (NZ quarterly; AU quarterly before April 2025) | 1915 to 1997, by country | BLS via ALFRED, ECB, ONS, BIS, SNB, Statistics Canada, ABS, OECD |
| US_UNRATE, EU_UNRATE, UK_UNRATE, JP_UNRATE, CH_UNRATE, CA_UNRATE, AU_UNRATE, NZ_UNRATE | Unemployment rate (UK: three months to the month; CH: registered job seekers) | percent, monthly (NZ quarterly) | 1948 to 1995, by country | BLS via ALFRED, ECB, ONS, OECD, SNB (SECO), Statistics Canada, ABS |
| USD_BCI, EUR_BCI, GBP_BCI, JPY_BCI, CHF_BCI, AUD_BCI, NZD_BCI, CAD_CLI | Business confidence (OECD BCI; for Canada the OECD leading indicator) | index, 100 = long-term average, monthly | 1950 to 1985, by area | OECD |
| US_PHILLY_MFG, EU_ESI | Faster surveys: Philadelphia Fed manufacturing, EC Economic Sentiment | diffusion index (0 = no change); index (100 = average), monthly | 1968, 1980 | Federal Reserve Bank of Philadelphia via ALFRED; Eurostat |
| FED_ASSETS, US_TGA_WED | Fed total assets and the Treasury's account, Wednesday levels (H.4.1) | USD millions, weekly | 2002 | Federal Reserve Board (H.4.1), via FRED |
| US_TGA_DAILY | Treasury General Account closing balance | USD millions, daily | 2005 | U.S. Treasury, Daily Treasury Statement |
| US_RRP | Fed overnight reverse repos | USD BILLIONS, daily | 2003 (sparse before 2013) | Federal Reserve Bank of New York, via FRED |
| ECB_ASSETS | Eurosystem total assets | EUR millions, weekly (as at Friday) | 1999 | ECB |
| US_NET_LIQUIDITY, US_NET_LIQUIDITY_DAILY | Fed assets less the TGA less reverse repo | USD millions, weekly and daily | 2002, 2005 | Derived by AnalyticsFX from the series above |
NO LOOKAHEAD, by frequency. A daily market close (US, Japanese and Canadian yields, real yields, breakevens, VIX) is readable from 00:00 UTC the day after its date; UK and German yields from 12:00 UTC on the next business day. SOFR and €STR are published the next business day and read from 13:00 UTC then. Oil spot is read three business days late, as the EIA posts it. The broad dollar index is published weekly (on Monday, Tuesday after a holiday) for the week before, and each value is read from that release, as dated by ALFRED. The Fed balance sheet and the Wednesday TGA are read from 22:00 UTC on the H.4.1 release day, the Thursday (Friday in a holiday week); the ECB's from the Wednesday after its Friday. A MONTHLY figure is dated on its month's first day and is readable only from its publication: the US CPI, unemployment rate and Philadelphia survey use ALFRED's first-release vintages, so a backtest reads the number as first printed on the day it was printed (September 2025's CPI on 24 October, after the shutdown); Canada's CPI uses Statistics Canada's own release times; every other figure is read after a conservative lag counted from the end of its month (from 1 day for the EC sentiment index to 60 days for Japan's CPI via the BIS), at least as late as its real release. Those other providers keep no vintages, so their history is today's revised data; from October 2026 on, the first value stored for each month is kept and never overwritten by a revision. US October 2025 CPI and unemployment were never published and stay gaps.
US NET LIQUIDITY. US_NET_LIQUIDITY is FED_ASSETS less US_TGA_WED less US_RRP times 1000, all as of the same Wednesday, readable when the H.4.1 is out and never carried before it. US_NET_LIQUIDITY_DAILY is, for each business day, what was public at 22:00 UTC: the last released Wednesday balance sheet, the previous business day's TGA from the Daily Treasury Statement, and that day's reverse repo. Its daily moves are the TGA and the reverse repo; the balance sheet term steps once a week.
Attribution, shown wherever the numbers are: Board of Governors of the Federal Reserve System (H.4.1, H.10, H.15); Federal Reserve Bank of St. Louis (breakevens); Chicago Board Options Exchange, CBOE Volatility Index: VIX, copyright Chicago Board Options Exchange, Inc., reprinted with permission; Federal Reserve Bank of New York (SOFR, reverse repo); U.S. Energy Information Administration; U.S. Bureau of Labor Statistics and Federal Reserve Bank of Philadelphia via ALFRED; all of these retrieved from FRED or ALFRED, Federal Reserve Bank of St. Louis. U.S. Department of the Treasury, Daily Treasury Statement. European Central Bank. Eurostat. Office for National Statistics (Open Government Licence v3.0). Statistics Canada (Statistics Canada Open Licence). Australian Bureau of Statistics (CC BY 4.0). OECD (CC BY 4.0). Swiss National Bank. Bank for International Settlements. Bank of England. get_macro_series returns each series' own line.
config = { symbol: "XAUUSD", interval: "1D", balance: 10000, leverage: 20 };
// Gold against the US 10 year real yield, and only while the VIX is calm.
const real = macro("US10Y_REAL");
const realMove = sub(real, ref(real, 20));
const vix = macro("VIX");
onBar = (ctx) => {
if (realMove.now === null || vix.now === null) return;
if (ctx.flat && realMove.now < -0.25 && vix.now < 25) ctx.buy({ lots: 0.1, stop: 0.03 });
if (ctx.long && realMove.now > 0) ctx.close();
};COT positioning is a series too, for this market or one written out.
| Call | What it reads |
|---|---|
| cot(symbol?) | Commercials' net position (longs less shorts) in the latest COT report for the market. |
| cotIndex(symbol?, weeks?) | Where that net position sits in the range of its last weeks reports, 0 at the low and 1 at the high. 156 weeks, three years, unless you say. Null until a year of reports is in. |
A COT report describes a Tuesday and is published on the Friday after, at 15:30 New York, later in a week with a US holiday. A bar reads a report only once it has been published, strictly before the bar opened: never on its Tuesday. Futures-backed markets only; a market with no report fails at the call, naming it, rather than reading as positioning of nothing. Write the symbol out, or leave it out for the strategy's own, because that is how the reports get loaded, in the editor and live.
Sessions and the clock, all read at the bar's open time, daylight saving included.
| Call | What it reads |
|---|---|
| inSession(session) | 1 while the session is open, else 0. "sydney", "tokyo", "london" and "newyork" are 08:00 to 17:00 local on the city's weekdays, as the chart shades them. Or a window of your own: { from: "09:30", to: "16:00", tz: "America/New_York", days: [1, 2, 3, 4, 5] }, which may cross midnight. |
| clock(part, tz?) | One field of the open time on a zone's clock: "hour", "minute", "weekday" (ISO), "monthday", "month", "year" or "dayMinute" (minutes after midnight). UTC unless a zone is given. |
| isFirstBarOf(period, tz?) | 1 on the first bar of each "day", "week" (from Monday), "month" or session ("session:london", or a window), else 0. Null on the first bar of the history. |
| barsSinceStart(period, tz?) | Bars since the period began, 0 on its first bar. Null outside a session. |
| sessionHigh(period, tz?) | The highest high since the period began, this bar included. |
| sessionLow(period, tz?) | The lowest low since the period began, this bar included. |
A period's first bar is found by comparing a bar with the one before it, never the one after, which is why the very first bar of a run answers null, and why a period already under way when the history starts is not counted into: its real first bar is not there to count from. These are computed on the host in every place a script runs, including the server's sandbox, so a live strategy reads the same session the chart does.
config = { name: "London opening range", symbol: "GBPUSD", interval: "15M", balance: 10000, leverage: 30 };
// The first hour of London is the range; a close outside it later in the session is the trade.
const into = barsSinceStart("london");
const top = sessionHigh("london");
const bottom = sessionLow("london");
let rangeHigh = null;
let rangeLow = null;
onBar = (ctx) => {
if (into.now === null) { rangeHigh = null; rangeLow = null; return; }
if (into.now === 3) { rangeHigh = top.now; rangeLow = bottom.now; }
if (rangeHigh === null || into.now < 4 || !ctx.flat) return;
if (close.now > rangeHigh) ctx.buy({ lots: 1, stopPrice: rangeLow, hold: 20 });
else if (close.now < rangeLow) ctx.sell({ lots: 1, stopPrice: rangeHigh, hold: 20 });
};The calendar's schedule, crypto sentiment and seasonality are series too, loaded before the run by reading your source.
| Call | What it reads |
|---|---|
| newsSince(currency, impact?) | Minutes from the last high-impact calendar item for the currency to this bar's open. "medium" counts medium and high. Null before the first. |
| newsAhead(currency, impact?) | Minutes from this bar's open to the next SCHEDULED release, within 24 hours. Null otherwise. |
| newsWithin(currency, minutes, impact?) | 1 where either of those is at most that many minutes, else 0. Up to 1440. |
| fearGreed() | The crypto Fear and Greed index, 0 to 100, market wide. From 2018. |
| seasonal(days?, years?) | This market's mean return over the next days calendar days from this date, across the years before this one, as a fraction. 20 days and 10 years unless you say. |
| seasonalHitRate(days?, years?) | The share of those years in which the window went up, 0 to 1. |
- newsSince counts every item that happened BEFORE the bar opened, speeches included. newsAhead is the one thing here that reads a time still to come, so it counts only releases that carry a published forecast: a consensus figure exists only for a release on the published schedule, and an unscheduled speech appears in the calendar at the minute it happened. It looks 24 hours ahead and no further, because that is how far the live calendar reaches, so a backtest cannot see further than the live strategy will.
- Write the currency out, newsWithin("USD", 30), as for surprise(): that is how the schedule gets loaded, in the editor and live. The minutes and the impact may be anything, input() included.
- fearGreed() reads a day's reading from the NEXT UTC day, because the index for a day is built from that day's own trading and is not complete until it is over.
- seasonal() reads this market's daily closes, loaded beside the run to the same depth in a backtest and live, and only years before the bar's own: a 2015 bar never reads 2016. Null until three earlier years are in the history.
- GEX is not offered: the options backend answers only for today, so there is no history a backtest could read without reading the present. Retail positioning is not offered because the platform does not hold it.
config = { name: "Quiet trend", symbol: "EURUSD", interval: "1H", balance: 10000, leverage: 30 };
// Stand aside for 45 minutes either side of high-impact news on either leg, and
// only trade with the seasonal tide.
const fast = ema(close, 20);
const slow = ema(close, 50);
const vol = atr(14);
const newsy = or(newsWithin("USD", 45), newsWithin("EUR", 45));
const tide = seasonal(20, 10);
onBar = (ctx) => {
if (!slow.ready || !vol.ready || newsy.now) return;
if (ctx.flat && fast.now > slow.now && (tide.now ?? 0) > 0) ctx.buy({ lots: 1, stop: 3 * vol.now });
if (ctx.long && fast.now < slow.now) ctx.close();
};Another timeframe and another market are read through a builder function.
const higher = tf("4H", (s) => s.ema(s.close, 50));
const gold = sym("XAUUSD", (s) => s.close);- tf reads another timeframe of the same symbol. It takes 15M, 30M, 1H, 4H, 1D and 1W, or the spellings M15, M30, H1, H4, D1, W1. A higher bar appears only from the bar that closes it. A lower timeframe gives each bar the last lower bar that closed by that bar's close, so a daily bar reads its last 4H bar, as Pine's request.security does.
- sym reads another instrument at this strategy's timeframe. Its value is placed at its own bar's open, and is null where that bar is more than two bars stale.
- Nothing can be READ inside a builder while it runs. It runs at a cursor of nowhere, so s.close.now is null there. A builder makes a series; it does not look at one. A builder that returns anything else is refused.
- A value of your own on the other market's bars is s.series(() => ...) made inside the builder: after the builder returns, the function is run once for every bar of that market, in order, and reads that bar and earlier ones only. tf("4H", (s) => s.series(() => (s.high.now - s.low.now) / s.close.ago(1))) is a 4H range in percent of the previous close, placed on these bars like any other.
- A builder may return an array of series, const [hi, lo] = tf("4H", (s) => [s.high, s.low]), for several values from one builder. Series made in it are written in the order they were made on each bar, so the first can compute what the others read, once per bar: Pine's tuple request.security.
- tf("4H", build, { forming: true }) reads a higher timeframe as it stands at each bar's close: the closed 4H bars, then the 4H bar still forming, built from this chart's own bars inside it so far. Its open is the first of those bars' open, its high and low the extremes so far, its close this bar's close and its volume their sum so far. The builder's series, s.series(...) and indicators included, are computed with that bar as the current one, so s.close.now is the forming close and s.close.ago(1) the last closed 4H close. It reads only bars up to this one, so it is causal, and it costs one step per bar, not the builder again.
- That is Pine's request.security with lookahead_on and no [1] offset, as TradingView shows it live. On history TradingView reads the 4H bar's final values from its first hour on, which is look-ahead, so the two differ only where the builder reads the forming bar's close, high, low or volume; its open and every earlier bar are the same. The buckets are whole 4H periods in UTC (weeks from Monday), and a chart whose timeframe does not divide the higher one, or higher bars that do not start on those boundaries, is refused with a sentence saying so. At or below the chart's timeframe the option changes nothing, since every bar read there has closed. The chart's first 4H bar is left empty when the chart starts after it opened.
- intervalSeconds() is the strategy's own timeframe in seconds, 3600 on 1H, for a script that behaves differently per timeframe.
- Write the timeframe and the symbol out as string literals. They are read out of your source before the run, which is how the other market's bars get loaded. A market worked out while the strategy runs has nothing behind it.
More indicators
These are the expression language's own builtins, one implementation shared by rules and scripts. Each reads a trailing window or folds forward from the first bar, so none of them reads ahead, and each is null while it warms up. Declare them at the top like any indicator and read them with .now.
| Call | What it reads |
|---|---|
| vwap(anchor?) | The volume weighted average of hlc3. With no argument it restarts at each UTC day; with an anchor series it restarts on every bar where the anchor is non-zero and starts a new period: vwap(isFirstBarOf("week")), vwap(isFirstBarOf("london")). Null until the period has traded. |
| vwma(series, length) | Volume weighted moving average. Null where any bar in the window has no volume. |
| obv() | On balance volume: the running sum of volume, signed by whether the close rose or fell. Null on the first bar. |
| mfi(length) | Money flow index, 0 to 100, on hlc3 and volume. Needs length + 1 bars. |
| linreg(series, length, offset?) | The least squares line over the last length values, read at this bar. A negative offset projects the line forward from past data: a forecast, not a look-ahead. |
| correlation(a, b, length) | Pearson correlation, -1 to 1. Null when either side is flat in the window or has a gap. |
| dmi_plus(length), dmi_minus(length) | Wilder's +DI and -DI, 0 to 100. With adx(length) they are Pine's ta.dmi(len, len): one length for the DI and the smoothing. |
| roc(series, length) | Rate of change in percent. Null where the earlier value is zero. |
| sar(start, increment, max) | Parabolic SAR, such as sar(0.02, 0.02, 0.2): the stop for the bar being decided. Null on the first bar. |
| valuewhen(condition, series, occurrence) | The series' value on the bar where the condition last held; occurrence 0 is the latest. The current bar counts. |
| rising(series, length), falling(series, length) | 1 when the value is above (below) EVERY one of its previous length values, as Pine defines it. Not a run of rising bars. |
| highestbars(series, length), lowestbars(series, length) | Bars back to the extreme of the window: 0 on this bar, -3 three bars ago. The most recent bar wins a tie. |
| median(series, length) | The middle value of the window; the mean of the two middle ones when length is even. |
vwap, vwma, obv and mfi read volume. On FX, metals and indices that is the feed's TICK COUNT, so they are tick weighted; a bar with no volume adds nothing, and a market with no volume at all fails loudly at the first read rather than handing back a series of nothing.
config = { name: "DMI trend above VWAP", symbol: "EURUSD", interval: "1H" };
const plus = dmi_plus(14);
const minus = dmi_minus(14);
const strength = adx(14);
const fair = vwap(); // restarts each UTC day
const lowAtCross = valuewhen(crossAbove(plus, minus), low, 0);
onBar = (ctx) => {
if (!strength.ready || plus.now === null || minus.now === null || fair.now === null || lowAtCross.now === null) return;
const up = strength.now! > 20 && plus.now > minus.now;
if (ctx.flat && up && close.now! > fair.now) {
ctx.buy({ risk: 0.5, stop: Math.max(close.now! - lowAtCross.now, 10 * symbol.pip) });
} else if (ctx.long && plus.now < minus.now) {
ctx.close();
}
};The second pack. Studies with several lines answer one object of series; each line is also a builtin of its own, which is what the expression language calls.
| Call | What it answers |
|---|---|
| alma(series, length, offset?, sigma?) | Arnaud Legoux moving average, Pine's ta.alma. offset 0.85 and sigma 6 unless you say. |
| tsi(series, short, long) | True strength index, -1 to 1. Its ema seeds with the first value, so the earliest readings differ from TradingView's until the seed has decayed, a few times long bars; later values agree. |
| wpr(length) | Williams %R, -100 to 0. |
| aroon(length?) | { up, down }, 0 to 100: how recently the last length + 1 bars made their high and their low. aroon_up(n) and aroon_down(n) singly. |
| ichimoku(conversion?, base?, spanB?, displacement?) | { tenkan, kijun, spanA, spanB, leadA, leadB }, 9, 26, 52, 26 unless you say. See below for which to read and which to draw. |
| pivotPoints(kind?, period?, tz?) | Floor pivots { pp, r1, s1, r2, s2, r3, s3 }, plus r4 and s4 for "camarilla". kind is "classic", "fib" or "camarilla"; period "day", "week" or "month" on tz's clock, UTC unless you say. Built from the PREVIOUS completed period, so a bar reads levels made before it opened; a period the history starts in the middle of is never used. |
| heikinAshi() | { open, high, low, close }: Heikin Ashi candles, folded forward from the first bar. Draw them with plotCandles(heikinAshi()). |
| zigzag(percent | { atr, mult }) | { pivot, ago, dir, extreme }: the last CONFIRMED turn's price, how many bars back its own bar is, 1 or -1 for the leg since it, and the open leg's extreme so far. Draw the line with drawZigzag(zz). |
| fractals(bars?) | { up, down }: Williams fractals, 2 bars each side unless you say. The price is read on the CONFIRMING bar, bars after the fractal, and is null elsewhere. |
Ichimoku has two pairs because a chart draws the cloud ahead of the bar that computed it. leadA and leadB are the values computed on this bar, which a chart draws displacement - 1 bars ahead. spanA and spanB are the cloud as it is drawn OVER this bar, computed displacement - 1 bars ago: spanA on a bar equals leadA 25 bars earlier. So read spanA and spanB, and draw leadA and leadB with an offset. The whole cloud, history and the 25 bars past the last bar, is plot(ich.leadA, { name: "A", offset: 25 }); plot(ich.leadB, { name: "B", offset: 25 }); fill("A", "B"). The lagging line is plot(close, { offset: -25 }), drawn back and never a value to read, because at this bar it would be a close from the future.
config = { kind: "indicator", name: "Cloud", overlay: true };
const ich = ichimoku(9, 26, 52, 26);
// leadA/leadB drawn 25 bars ahead ARE the cloud (spanA/spanB are the same values, read on their own bar).
plot(ich.leadA, { name: "A", color: "#22c55e", offset: 25 });
plot(ich.leadB, { name: "B", color: "#ef4444", offset: 25 });
fill("A", "B", { opacity: 0.15 });
onBar = () => {
const { spanA, spanB } = ich;
if (spanA.now === null || spanB.now === null) return;
if (close.ago(1)! <= Math.max(spanA.ago(1)!, spanB.ago(1)!) && close.now! > Math.max(spanA.now, spanB.now)) mark("above", { shape: "arrowUp", position: "below" });
};zigzag(1.5) confirms a turn when price comes back 1.5 percent from the leg's extreme; zigzag({ atr: 14, mult: 3 }) when it comes back 3 times atr(14), read on the confirming bar, and no turn confirms before the ATR has a value. One event per bar: a bar that makes a new extreme does not also reverse. The LAST LEG REPAINTS on a chart, its end moving with every new extreme until the next turn confirms, but every value is read from the bars up to this one. drawZigzag(zz) draws the whole line, every confirmed turn (the newest 500) and the open leg, as one drawing: never rebuild it with a loop of your own. 4 zigzags at most.
config = { kind: "indicator", name: "ATR zigzag", overlay: true };
const zz = zigzag({ atr: 14, mult: 3 });
// One drawing: every confirmed turn, then the open leg, which repaints until the next turn confirms.
drawZigzag(zz, { color: "#f59e0b", width: 2 });
onBar = () => {
// The series read confirmed turns only: a new turn is a change of dir, its price is pivot.
if (zz.dir.ago(1) !== null && zz.dir.now !== zz.dir.ago(1)) mark(zz.dir.now === 1 ? "low" : "high", { price: zz.pivot.now! });
};config = { kind: "indicator", name: "Heikin Ashi", overlay: true };
// Folded forward from the first bar, so causal, and drawn over the price.
plotCandles(heikinAshi(), { up: "#26a69a", down: "#ef5350" });plotCandles(o, h, l, c) or plotCandles({ open, high, low, close }) draws candles from four series. pane is "price" unless you name a pane of their own; up and down colour the candles that closed at or above their open and below it. One set per name ("candles" unless you say), 4 sets at most, and a bar is drawn only where all four have a value.
config = { kind: "indicator", name: "Pivots, fractals and momentum", overlay: true };
const pv = pivotPoints("classic", "day");
const fr = fractals(2);
const ar = aroon(14);
plot(pv.pp, { name: "pp", style: "step", color: "#94a3b8" });
plot(pv.r1, { name: "r1", style: "step", color: "#fb7185" });
plot(pv.s1, { name: "s1", style: "step", color: "#34d399" });
plot(alma(close, 21), { name: "alma" });
plot(tsi(close, 13, 25), { name: "tsi", pane: "tsi" });
plot(wpr(14), { name: "wpr", pane: "wpr" });
plot(ar.up, { name: "aroon up", pane: "aroon", color: "#34d399" });
plot(ar.down, { name: "aroon down", pane: "aroon", color: "#fb7185" });
onBar = () => {
// A fractal is known only on the bar that confirms it, two bars after its own.
if (fr.up.now !== null) mark("", { shape: "triangleDown", price: fr.up.now });
if (fr.down.now !== null) mark("", { shape: "triangleUp", price: fr.down.now });
};Currency rates and lower timeframes
fx(from, to) is what one unit of from is worth in to, bar by bar: Pine's request.currency_rate. It is built from each currency's USD leg (EURUSD, USDJPY and the like), the very bars and alignment the engine converts a cross's profit with, so fx("JPY", "USD") is the rate a EURJPY run's profit is booked at.
- Write both currencies out as string literals: fx("EUR", "USD"). That is how the rates are loaded, in the editor and live; a currency worked out while the script runs is refused.
- It is causal: a bar reads the last leg bar that opened at or before it, and null before the legs begin.
- USD and the currencies quoted against it only. A metal is not a currency here: fx("XAU", "USD") is refused.
- It is refused on the backtest replay chart, whose rates would be loaded up to now rather than up to the replayed bar. Use the script on the live or research chart.
config = { kind: "indicator", name: "Euro in dollars" };
// The rate the engine books a euro at, from the same USD legs it converts profit with.
const eur = fx("EUR", "USD");
plot(eur, { name: "EUR in USD", pane: "rate" });intrabar(timeframe, build) reads the LOWER bars inside each bar, Pine's request.security_lower_tf. const q = intrabar("15M", (s) => s.close) answers, at each bar, q.now: an array of the 15M closes inside it, oldest first, from the lower bars that opened at or after its open and closed by its close. q.ago(k) is the array k bars back. The builder runs over the lower bars, so s.ema(s.close, 20) and s.series(() => ...) work there; one series per call, and a value still warming up is null in its place. The arrays are frozen copies.
Leave the builder out for the lower bars themselves: intrabar("15M").now is an array of { time, open, high, low, close, volume }, one per lower bar inside this one, oldest first, each frozen, volume null where the market has none. So for (const b of intrabar("15M").now) if (b.close > b.open) up += b.volume ?? 0 adds up this bar's up volume.
- The timeframe is 1M, 5M, 15M, 30M, 1H, 4H or 1D, lower than the chart's and dividing it, written out in quotes: that is how the lower bars are loaded, live too. Or "auto", which reads the finest of them with at most 12 inside one chart bar: 1M on charts of 2M to 10M, 5M on 15M to 1H, 15M on 2H, 30M on 4H, 1H on 12H, 4H on 1D, 1D on a week or a month. It depends on the chart's timeframe alone, so a backtest, the chart and a live run read the same lower bars. TradingView's own automatic choice is finer (one minute on any intraday chart); this one keeps a few thousand chart bars inside the budget. A one-minute chart has nothing below it, and "auto" there is refused.
- Or an input.timeframe() with its options written out, const lower = input.timeframe("lower", "auto", { options: ["auto", "5M", "15M"] }): every option is loaded before the run, as tf()'s are, and the reader picks one in the panel. In a strategy an option over the budget is skipped by the loader and refused only if it is picked; an indicator loads it for its newest bars, as below, but only while it is the option picked, so an option nobody picks costs nothing.
- The budget: one run reads at most 50,000 lower bars. A strategy over a longer range is refused before it runs (its backtest, Optimise and the saved indicators it reads alike), and the live window of 1,500 bars is checked when the strategy goes live and when a script alert is created. Use the highest lower timeframe that answers the question.
- An indicator on the chart, in the Builder's preview and in its debugger is not refused over the budget: as TradingView draws request.security_lower_tf past its limit, it reads the lower bars of its newest chart bars, as many as 50,000 lower bars cover, and the older chart bars have none (intrabar() reads [] there, volumeDelta() na). The run says so under the chart, for example "1M bars cover only the last 833 of the 900 chart bars: one run loads at most 50000 1M bars, so the 67 before them have no 1M data". A 1H chart of 900 bars reading 1M needs about 54,000; it draws, and its oldest bars read []. Only a run where not even its last bar fits (1M inside a monthly bar) is still refused.
- An array is empty where no lower bar is inside: a session break, a missing bar, or before the lower bars loaded.
- A strategy whose lower bars do not reach back to its first bar is refused, because a condition that silently never fires on its oldest bars changes a result you trust. An indicator's run reads [] on the bars before them and says where the lower bars begin. Live and in alerts the oldest bars read [] instead when the lower history is shorter than the live window, which only affects warm-up; a bar after a holiday can read [] live while the backtest has its lower bars.
- It is refused on the backtest replay chart, as fx() is, because its lower bars cannot follow the replay.
config = { name: "Lower-timeframe pressure", symbol: "EURUSD", interval: "1H" };
// The 15M closes inside each 1H bar. A run reads at most 50,000 lower bars, so keep the range sensible.
const closes15 = intrabar("15M", (s) => s.close);
const vol = atr(14);
onBar = (ctx) => {
const q = closes15.now;
if (!vol.ready || q.length < 3) return;
let ups = 0;
for (let i = 1; i < q.length; i++) if ((q[i] ?? 0) > (q[i - 1] ?? 0)) ups++;
if (ctx.flat && ups === q.length - 1) ctx.buy({ risk: 0.5, stop: 2 * vol.now!, hold: 6 });
else if (ctx.long && ups === 0) ctx.close();
};volumeDelta(timeframe, { anchor }) is TradingView's ta.requestVolumeDelta, computed from the lower bars inside each bar, read and loaded as intrabar() reads them (the same timeframes, "auto" and input.timeframe() included, and the same budget). Each lower bar's volume counts as up when it closed above its open and down when it closed below it; one that closed at its open is up when its close is at or above the lower bar before it, down when below. It answers seven series:
- open, high, low and close: the cumulative volume delta when the bar opened, the highest and lowest it reached inside the bar (its open included) and where it closed. plotCandles() draws them as they are.
- The delta runs on from bar to bar and starts again at 0 on the first bar of each anchor period: "1D", "1W", "M1" (a month) or any chart timeframe, counted in UTC (a week from Monday). With no anchor, or one no longer than the chart's bar, every bar starts at 0, which is the bar's own delta.
- up, down and delta: the bar's own up volume, down volume (0 or less) and their sum, TradingView's ta.requestUpAndDownVolume.
- A bar with no lower bars inside it reads null in all seven, and the running delta holds where it was. A volume the market does not have counts as 0, so on a feed without volume the delta is flat.
- Not inside a builder, and refused on the backtest replay chart, as intrabar() is.
config = { kind: "indicator", name: "Volume delta" };
// The lower timeframe is an input: every option is loaded before the run, and "auto" picks one from the chart's.
const lower = input.timeframe("lower", "15M", { options: ["15M", "auto"] });
// The cumulative delta, starting again at 0 each UTC day, as candles in a pane of its own.
const cvd = volumeDelta(lower, { anchor: "1D" });
plotCandles(cvd, { name: "CVD", pane: "cvd" });
plot(cvd.delta, { name: "bar delta", pane: "delta", style: "histogram" });
// The lower bars themselves: the share of this bar's volume that traded in rising lower bars.
const inside = intrabar(lower);
onBar = () => {
let up = 0;
let all = 0;
for (const b of inside.now) {
all += b.volume ?? 0;
if (b.close > b.open) up += b.volume ?? 0;
}
plot("up share", all > 0 ? up / all : null, { pane: "share" });
};Series you make
The indicators are a ceiling only if they are all there is. Maths on whole series, and series you write yourself, both return a series, so both feed any indicator.
| Call | Meaning, bar by bar |
|---|---|
| add(a, b), sub, mul, div | Arithmetic. div is null where b is zero. |
| gt(a, b), lt, gte, lte, eq, neq | Comparisons: 1 or 0. |
| and(a, b), or, not(a) | Logic on non-zero: 1 or 0. |
| iff(test, a, b) | a where test is non-zero, b where it is zero. |
| series(fn) | A series written for you: fn() is called at every bar, before onBar. |
| series(label?) | A series you write inside onBar with .set(value). |
Either side of series maths may be a plain number, and a null on either side is a null in the result. close.now - open.now is a number, enough for a decision and useless to an indicator; sub(close, open) is the series, so ema(sub(close, open), 10) is the platform's ema of the platform's subtraction.
config = { name: "Where it closed", symbol: "EURUSD", interval: "1H", balance: 10000, leverage: 30 };
// Written for you, at every bar, before onBar.
const pressure = series(() => (close.now - low.now) / Math.max(high.now - low.now, 1e-9));
// Written by you, inside onBar.
const conviction = series("conviction");
const smooth = ema(pressure, 10);
const hot = crossAbove(ema(conviction, 5), 0.6);
const wide = gt(sub(high, low), sma(sub(high, low), 20));
const vol = atr(14);
onBar = (ctx) => {
conviction.set(smooth.ready && smooth.now > 0.5 ? 1 : 0);
if (!vol.ready) return;
if (ctx.flat && hot.now && wide.now) ctx.buy({ lots: 0.5, stop: 2 * vol.now });
if (ctx.long && smooth.now < 0.4) ctx.close();
};set() takes a number, true or false (stored as 1 and 0), or null. It takes no index, and that is the whole causality argument for it: the only bar it can write is the one being decided. A bar nobody wrote reads as null. Writing again on the same bar replaces the value.
series(fn) is not computed over every bar up front, the way sub(close, open) is, because fn is your code and your code can keep state: run over every bar at the top of the file it could collect every close into an array. Run one bar at a time as the run reaches each bar, it can only see what has happened.
An indicator over a series you write is computed as the series grows: the bars already decided are final and computed once, the bar being decided again whenever it is written. It agrees with the same indicator computed the ordinary way to the last decimal at every bar, and a 50,000 bar run stays well under a second.
series() cannot be made inside a tf() or sym() builder, since there is no bar there to write. Series maths can: sym("XAUUSD", (s) => s.div(s.close, s.sma(s.close, 20))).
Algorithms
A strategy is real code, so an algorithm is written the way it would be anywhere: loops, arrays, Maps, classes, closures and state carried between bars all work. Two things make it short. s.window(n) hands out a series' recent history as a plain array, and stats holds the statistics you would otherwise write by hand.
| Call | What it answers |
|---|---|
| s.window(n) | The last n readings of any series, OLDEST first, ending at the bar being decided. Shorter on the first bars; n is a whole number from 1 to 5000. |
| stats.mean(v), stats.sum, stats.min, stats.max, stats.median | The obvious, over the finite values. |
| stats.variance(v), stats.stdev(v) | Population variance and standard deviation. |
| stats.percentile(v, p) | The p-th percentile, p from 0 to 100, interpolated between neighbours. |
| stats.zscore(v) | How many standard deviations the LAST value sits from the mean of the array. |
| stats.correlation(a, b), stats.covariance(a, b) | Paired from the last element, so windows of different lengths line up at this bar. Only pairs where both hold a number count. |
| stats.slope(v) | The least-squares slope against position 0, 1, 2 ...: change per bar. |
| stats.linreg(v) | { slope, intercept, r2, at(x) }: the least-squares line. at(v.length - 1) is its value at this bar. |
Every stats function skips nulls and non-finite values, because a window over an indicator's first bars holds nulls, and answers null when nothing finite is left, which you test for the way you test .ready. linreg keeps each value's POSITION when it skips one, so a gap stays a gap. The functions are pure and the object is frozen.
window() is exactly as causal as ago(): the newest value in it is this bar's, and nothing after the bar being decided is ever in it. It is a copy, so writing into it changes nothing, and inside a tf() or sym() builder, which has no bar, it is empty. The same stats source runs in the browser and in the live sandbox, so a regression gives the same number in both.
config = { name: "Regression channel", symbol: "EURUSD", interval: "1H", balance: 10000, leverage: 30 };
const len = input("len", 50, { min: 20, max: 200, step: 10 });
const vol = atr(14);
onBar = (ctx) => {
const w = close.window(len);
if (w.length < len || !vol.ready) return;
const fit = stats.linreg(w);
if (!fit) return;
// The line's value at this bar, and how far closes stray from it.
const mid = fit.at(len - 1);
const spread = stats.stdev(w.map((v, k) => v - fit.at(k)));
plot("mid", mid, { color: "#60a5fa" });
plot("upper", mid + 2 * spread, { color: "#94a3b8" });
plot("lower", mid - 2 * spread, { color: "#94a3b8" });
plot("fit", fit.r2, { pane: "fit quality", style: "area" });
if (ctx.flat && fit.slope > 0 && fit.r2 > 0.6 && close.now < mid - spread) {
ctx.buy({ lots: 0.5, stop: 2 * vol.now });
} else if (ctx.long && close.now > mid + spread) {
ctx.close();
}
};
fill("upper", "lower", { opacity: 0.08 });When an algorithm is a series, prefer series(fn) and the platform's indicators to a window per bar: ema(series(() => ...), 10) is computed incrementally, where a window of 200 read on every bar of a 50,000 bar run copies ten million numbers.
Market info and money
symbol describes the market the script runs on, also spelled syminfo, Pine's name. Every field comes from the platform's symbols table, the contract size included, so a script never needs 0.0001 or 100000 written into it: read symbol.pip and symbol.contractSize instead.
| Field | What it holds |
|---|---|
| symbol.name, symbol.description | The market as the platform names it, "EURUSD" (no exchange prefix), and the table's description, "EUR / USD". |
| symbol.type | forex, metal, energy, agriculture, index, crypto, stock, etf, bond or other. |
| symbol.base, symbol.quote | "EUR" and "USD" on EURUSD. base is null on an index or a commodity future; quote is the currency the price is in. |
| symbol.accountCurrency | USD: what balance, equity and profit are in. |
| symbol.pip, symbol.tickSize, symbol.digits | The pip the backtester uses (0.0001, 0.01 on a JPY quote, 1 off FX), the smallest price step, and the decimals of a tick. |
| symbol.contractSize | Units per lot: 100000 on FX, 100 on gold, 1 on an index. Throws when the table has none, rather than guessing. |
| symbol.pointValue, symbol.pipValue | Account money per 1.0 of price per lot, and per pip per lot, at the bar being decided. |
| symbol.minLots, symbol.lotStep | The smallest position the engine opens and the step it sizes in: 0.001. |
| symbol.session, symbol.isOpen | The instrument's own trading hours from the table, { spec, tz, weekends }, and a series that is 1 while they are open. Not the London or New York session: those are inSession(). |
| symbol.hasVolume, symbol.cotKey | Whether volume is traded volume (crypto, stocks, ETFs) or a tick count (FX, metals, indices), and the COT report this market reads, or null. |
| Call | What it answers |
|---|---|
| symbol.roundPrice(price) | A price rounded to the nearest tick. |
| symbol.roundLots(lots) | Lots floored to the 0.001 step the engine sizes in; a positive size below it is 0.001. |
| symbol.toAccount(distance, lots?) | Account money of a price move of distance for lots (1 unless given). |
| symbol.lotsFor(money, stop) | The lots that lose money if a stop that far away in price is hit: what an order's riskMoney sizes to, floored to 0.001, before the engine caps it at the margin the balance holds. |
| symbol.of("XAUUSD") | Another market's static fields: symbol.of("XAUUSD").contractSize. Its money fields (pointValue, pipValue, toAccount, lotsFor) throw. |
- The money fields are read at the bar being decided with the engine's own conversion, so a size worked out from them is the size the backtest gives.
- On a cross the conversion comes from real bars of the quote currency's USD pair (USDJPY for EURJPY), the same bars in a backtest and live; a fixed reference rate is used only where those bars did not load. Live, when a cross's conversion bars do not load and the script reads money, that bar is refused rather than sized at a guessed rate.
- Only an FX pair's quote is converted from its own currency. An index or a commodity quoted outside USD is converted by the engine at 1 or at a fixed reference rate; pointValue follows the engine, while quote says what the price is really in.
- symbol.of() takes a string literal, written out: the market is loaded before the run. Static fields only. Inside a tf() or sym() builder, s.symbol describes that market, without the money fields.
config = { name: "Risk-sized breakout", symbol: "EURUSD", interval: "1H" };
const vol = atr(14);
const hi = highest(high, 20);
onBar = (ctx) => {
if (!vol.ready || !hi.ready) return;
const stop = 2 * vol.now!;
if (ctx.flat && close.now! > hi.ago(1)!) {
// Half a percent of equity lost at the stop, in this market's own lots and money.
const lots = symbol.roundLots(symbol.lotsFor(ctx.equity * 0.005, stop));
if (lots >= symbol.minLots) ctx.buy({ lots, stop, target: 2 * stop });
} else if (ctx.long && close.now! < hi.now! - 30 * symbol.pip) {
ctx.close();
}
};config = { kind: "indicator", name: "Market card" };
const gold = symbol.of("XAUUSD");
plot(atr(14), { name: "atr", pane: "atr" });
onBar = () => {
if (!barState.isLast) return;
table.grid([
["Market", symbol.name],
["Pip value per lot", symbol.pipValue],
["20 pips on 0.5 lots", symbol.toAccount(20 * symbol.pip, 0.5)],
["Gold lot size", gold.contractSize],
]);
};Bar state and time
| Name | What it answers |
|---|---|
| barState.isFirst | True on the run's first bar, and at the top of the file. |
| barState.isLast | True on the last bar of the data. In a backtest that is where the history ends; live, every decided bar is. |
| barState.isLive | True only on the bar the live runner or the alert runner is deciding for real. Never in a backtest, a preview or a chart. |
| barState.isHistory | The opposite of isLive. |
| ctx.lastIndex | The index of the run's last bar, known before the first: Pine's last_bar_index. It says how long the data is, never what a later bar holds. |
| timeClose | A series: each bar's close time in epoch milliseconds, its open time plus the timeframe. ctx.bar.time is the open. |
| timestamp(year, month, day, hour?, minute?, tz?) | Epoch milliseconds of a wall-clock time: timestamp(2024, 3, 15, 9, 30, "America/New_York"). The month is 1 to 12; UTC unless a zone is given. |
| formatTime(time, pattern, tz?) | An instant as text: formatTime(ctx.bar.time, "yyyy-MM-dd HH:mm", "Europe/London"). Tokens yyyy yy MMM MM M dd d HH H mm m ss s SSS EEE; text in single quotes is kept. English names, 24-hour clock, UTC unless a zone is given. |
| isFirstBarOf(period, tz?), isLastBarOf(period, tz?) | 1 on the first or the last bar of each "day", "week", "month" or session, else 0. The last bar is the one whose close reaches the end of its period on the clock, decided from its own time, never from a later bar; a bar whose next slot falls in a market closure is not the last of its week by this test. |
barState is for drawing and alerts, not for trades. On history isLast is one bar and isLive none, while live every decided bar is both, so a trade gated on either makes the backtest differ from live. The same goes for ctx.index === ctx.lastIndex. Draw a table on isLast; alert on isLive.
Every run is reproducible. Math.random() is seeded by the script's text and its inputs, so the same file with the same inputs draws the same numbers in the browser, the sandbox and live; random() draws from the same stream, and random(seed) from that seed's own stream, the same numbers in every run of any script (100 seeds at most in one run). Date.now() and new Date() with no argument are the CLOSE time of the bar being decided, and the top of the file reads the first bar's close: a backtest never sees the wall clock, which would be a look-ahead. Date.now() does not advance within a bar, so never loop until a time passes. new Date(ms) with an argument is the ordinary Date. Its text and its local getters (getHours, getDate, toString) depend on the time zone of the machine the script runs on, so use getUTCHours() and the other getUTC* methods, or formatTime(ms, pattern, tz).
config = { kind: "indicator", name: "Day closes", overlay: true };
const lastOfDay = isLastBarOf("day");
const firstOfWeek = isFirstBarOf("week");
plot(sma(close, 24), { name: "day average" });
onBar = (ctx) => {
if (lastOfDay.now === 1) mark(formatTime(timeClose.now!, "EEE HH:mm"), { shape: "circle", position: "above" });
if (firstOfWeek.now === 1) bgcolor("rgba(96, 165, 250, 0.08)");
if (barState.isLast) {
// The bar clock: new Date() is this bar's close, read in UTC.
const closed = new Date();
label("bar " + ctx.index + " of " + ctx.lastIndex + ", closed " + closed.getUTCHours() + "h UTC", { at: { ago: 0, price: high.now! } });
}
if (barState.isLive) alert("{{symbol}} closed at {{close}}", { key: "close" });
};Showing your work
A strategy that cannot show anything cannot be debugged: a rule that never fires leaves nothing to look at but a trade count of zero. Three things are drawn or printed after the run, each against the bar that produced it. None of them reaches the engine, so none can change a result.
| Call | What it does |
|---|---|
| plot(series, { name?, color?, pane? }) | Draws a whole series on the chart. Anywhere in the file. |
| plot("name", value, { color?, pane? }) | Draws one value at the bar being decided. Inside onBar. |
| mark("text", { shape?, color?, price? }) | Marks the bar being decided: shape is "note", "arrowUp" or "arrowDown". |
| print(...values) | Writes a line to the panel under the editor, with its bar. console.log does the same. |
| metric("name", value) | Records a number of your own on the bar being decided, inside onBar. Shown after the run as its last reading, mean, min, max and count, and rankable by Optimise. |
| alert("message", { key?, once?, freq?, data? }) | Raises an alert on the bar being decided, inside onBar. Shown on the chart where it would have fired; sent to your email and alert channels when you turn alerts on. See Alerts. |
| watch("name", value) | Records a number, true or false, a string or null on the bar being decided, inside onBar, for the debugger to show beside that bar. Changes nothing outside a debug run. See Debugging. |
pane is "price", "below", or a name of your own for a pane of its own. Leave it out and it is chosen from the values: a line that sits near the price goes on the price, anything else in a pane of its own. A series prints as its name and its reading, as in ema(close,20)=1.08412. The log keeps 1,000 lines, the chart the most recent 2,000 marks, and a script can draw 32 plots; past those, lines and marks are counted and dropped. Styles, fills, levels, backgrounds, candle colours, lines and boxes are in Drawing on the chart; colours that follow the value in Colouring by value.
It is print and not log because log(x) is the natural logarithm, and was long before there was anything to print.
config = { name: "Why it never fires", symbol: "EURUSD", interval: "1H", balance: 10000, leverage: 30 };
const fast = ema(close, 20);
const slow = ema(close, 50);
const vol = atr(14);
plot(fast);
plot(slow, { name: "slow", color: "#f59e0b" });
onBar = (ctx) => {
if (!vol.ready || !slow.ready) return;
plot("spread in ATRs", (fast.now - slow.now) / vol.now, { pane: "below" });
if (ctx.flat && crossAbove(fast, slow).now) {
mark("in", { shape: "arrowUp" });
print("cross at", ctx.bar.close, "atr", vol);
ctx.buy({ lots: 0.5, stop: 2 * vol.now });
} else if (ctx.long && crossBelow(fast, slow).now) {
mark("out", { shape: "arrowDown" });
ctx.close();
}
};A name the runtime provides can be declared again by the strategy, and then the strategy's own wins: const mark = 3 is a number in that file, as it was before mark() existed.
Debugging
Debug, beside Profile in the Builder, runs the file once in your browser and records it bar by bar. A slider steps through the bars (left and right arrow keys too, and Shift with them jumps to the next decision), with buttons to jump to the next decision, trade or print. For each bar it shows the bar's time and prices; every series declared at the top of the file, as .now and .ago(1); every top-level variable that holds a number, true or false, or a short string, as it stood after that bar's onBar; what watch() recorded; what the strategy asked for (buy, sell, close, modify, reduce) and what the rules made of it, including an order that was not acted on and why (already long, maxTrades reached, minHold not met, nothing held to close); the position and equity onBar saw; and what was printed, marked and alerted. A value that changed since the bar before is tinted.
The run is the one Run would make, and a strategy's trades go on the chart with it. While the debugger is open the chart marks the selected bar, and clicking a candle selects it. The last 5,000 bars are kept; earlier ones are counted and said. A run that throws keeps every bar up to the one it threw on, and opens there. A portfolio (config.markets, markets: [] included) is stepped through on its own market's bars, the clock it is decided on: each bar shows, market by market, what it decided (opens with the position key they will carry, closes, moves and reduces, and the ones refused with the reason, such as maxPositions or maxEntries), what happened during the bar (fills, stops, targets, orders refused at the open or cancelled by oco), the positions and entry orders held after it, and the equity. The chart shows the portfolio's own market, so its marker and clicks work on that market's bars; the other markets act at their own next open and are not drawn there.
The debugger reads the names the file declares at its top level. A variable declared inside onBar lives only while that bar runs, so it cannot be read afterwards: record it with watch("name", value). Reading a name never changes it, and watch() does nothing outside a debug run, so a file debugged and a file run give the same trades.
config = { name: "Stepping through a pullback", symbol: "EURUSD", interval: "1H", balance: 10000, leverage: 30 };
const trend = ema(close, 50);
const vol = atr(14);
let pullbacks = 0;
onBar = (ctx) => {
if (!trend.ready || !vol.ready) return;
// A local: the debugger cannot see it by itself, so it is watched.
const depth = (trend.now - low.now) / vol.now;
watch("depth in ATRs", depth);
watch("above trend", close.now > trend.now);
if (depth > 0.5) pullbacks++;
if (ctx.flat && depth > 0.5 && close.now > trend.now) ctx.buy({ lots: 0.5, stop: 2 * vol.now });
else if (ctx.long && close.now < trend.now) ctx.close();
};Drawing on the chart
Past a line per series, a strategy or an indicator can draw most of what a chart shows. Everything is recorded against the bar being decided unless it says anywhere, is capped, and never reaches the engine.
| Call | What it draws | Where |
|---|---|---|
| plot(..., { style, width, hidden, offset }) | style is "line", "step", "dashed", "histogram", "columns", "area" or "dots"; width 1 to 6. hidden: true keeps it for a fill() and draws nothing. offset: n draws each value n bars to the right (negative: left): see Drawings that move. | Anywhere |
| plot("name", value, { color }) | The colour is kept PER BAR, so one histogram can be green above zero and red below. See Colouring by value. | onBar |
| fill("a", "b" | level, { color?, opacity?, gradient? }) | Shading between two plots by name, or between a plot and a level. opacity 0 to 1, 0.2 unless you say. Called inside onBar it colours the bar being decided, { color: null } leaving that bar unfilled; gradient: { top, bottom, topColor, bottomColor } shades from one colour to the other. See Colouring by value. | Anywhere |
| hline(price, { color?, style?, label?, pane? }) | A level across the whole chart. style is "solid", "dashed" (the default) or "dotted". | Anywhere |
| bgcolor(color | null, { pane? }) | Shades this bar's background, on the price unless you name a pane. null leaves it. | onBar |
| barcolor(color | null) | Colours this bar's candle. null leaves it the chart's own. | onBar |
| mark("text", { shape?, position?, offset? }) | shape is "note", "arrowUp", "arrowDown", "triangleUp", "triangleDown" or "circle"; a triangle or a circle may have empty text. position "above" puts it over the high, "below" under the low. offset: n draws it n bars to the right (negative: left) of the bar that made it, -500 to 500: see Drawings that move. | onBar |
| drawLine(from, to, { color?, width?, style?, extend? }) | A line between two bars. extend is "none", "right" or "both". Answers a handle: see Drawings that move. | onBar |
| drawBox(from, to, { color?, fill?, width?, style? }) | A box between two corners. fill is its shading, none unless you say. Answers a handle. | onBar |
| plotCandles(open, high, low, close, { up?, down?, pane?, name? }) | Candles from four series, such as plotCandles(heikinAshi()). See More indicators. | Anywhere |
A point is { ago: k, price } for k bars back from this one (0 is this bar), or { time, price } for a bar's open time. Both name this bar or an earlier one: a negative ago or a time after this bar throws, because a drawing placed on a bar that has not happened is a drawing only the future could have made. The one way to the right is { ahead: n, price }, a projection: see Drawings that move.
pane is "price", "below", or a name of your own: every distinct name is a pane of its own under the chart, so a plot, an hline and a bgcolor that share { pane: "momentum" } share a pane. A colour is any CSS colour string up to 40 characters: "#34d399", "rgba(52, 211, 153, 0.2)", "teal".
The caps: 32 plots, 24 fills, 50 levels, and 500 each of lines, boxes, polylines and labels, of which the most recent are kept and the rest counted. The same level drawn on every bar is one level; a level that moves is a plot. A fill that names a plot nobody drew is an error after the run, naming it.
config = { name: "Breakout, drawn", symbol: "EURUSD", interval: "1H", balance: 10000, leverage: 30 };
const top = highest(high, 20);
const bottom = lowest(low, 20);
const fast = ema(close, 10);
const slow = ema(close, 30);
plot(top, { name: "top", color: "#94a3b8", style: "step" });
plot(bottom, { name: "bottom", color: "#94a3b8", style: "step" });
fill("top", "bottom", { color: "#94a3b8", opacity: 0.06 });
hline(0, { pane: "momentum", style: "dotted" });
onBar = (ctx) => {
if (!slow.ready || ctx.index < 21) return;
const gap = fast.now - slow.now;
plot("momentum", gap, { pane: "momentum", style: "histogram", color: gap >= 0 ? "#34d399" : "#fb7185" });
bgcolor(ctx.long ? "rgba(52, 211, 153, 0.08)" : null);
if (ctx.flat && close.now > top.ago(1)) {
barcolor("#facc15");
mark("breakout", { shape: "arrowUp", position: "below" });
// The range it broke out of, and the level it broke.
drawBox({ ago: 20, price: top.ago(1) }, { ago: 1, price: bottom.ago(1) }, { color: "#60a5fa", fill: "rgba(96, 165, 250, 0.12)" });
drawLine({ ago: 0, price: top.ago(1) }, { ago: 0, price: top.ago(1) }, { extend: "right", style: "dashed" });
ctx.buy({ lots: 0.5, stop: top.now - bottom.now });
} else if (ctx.long && gap < 0) {
ctx.close();
}
};Colouring by value
A line, a histogram or a candle can change colour with its value. The whole-series form, plot(series, { color }), is drawn once for the whole history, so it has ONE colour. To colour by value, draw the value inside onBar with plot("name", value, { color }): the colour is kept per bar, so a line can be green while it rises and red while it falls.
| Call | What it colours | Where |
|---|---|---|
| plot("name", value, { color }) | This bar's value of the plot, in this colour. | onBar |
| plot("name", value, { style: "histogram", color }) | One histogram bar, so a histogram can be green above zero and red below, or pale while it shrinks. | onBar |
| barcolor(color | null) | This bar's candle. null leaves the chart's own colour. | onBar |
| bgcolor(color | null, { pane? }) | This bar's background, on the price unless you name a pane. | onBar |
| fill("a", "b", { color }) | This bar's shading between two plots, Pine's fill with a colour per bar. { color: null } leaves this bar unfilled; a bar no call coloured keeps the fill's own colour. | onBar |
| fill("a", "b" | level, { gradient: { top, bottom, topColor, bottomColor } }) | A vertical gradient between the two edges, as Pine's fill(p1, p2, top_value, bottom_value, top_color, bottom_color): topColor at the price top, bottomColor at bottom, held at the end colours beyond them. At the top of the file top and bottom are series or levels; inside onBar they are this bar's numbers and the colours this bar's. A null colour is that end transparent, and a bar without a top or a bottom is not filled. | Anywhere |
| plot(series, { color }) | The whole series in one colour. | Anywhere |
A fill's colours carry their own alpha, which opacity multiplies: give the colour as rgba() and opacity: 1, and each bar shows exactly the colour it was given. A cloud green while the fast average is above the slow one and red below, an RSI shaded in a gradient that is strongest at 70, and each swing high marked on its own bar:
config = { kind: "indicator", name: "Coloured cloud" };
const fast = ema(close, 12);
const slow = ema(close, 26);
plot(fast, { name: "fast", color: "#22c55e", pane: "price" });
plot(slow, { name: "slow", color: "#ef4444", pane: "price" });
const r = rsi(close, 14);
plot(r, { name: "rsi", pane: "rsi", color: "#a78bfa" });
// Full colour at 70, transparent at 50, held beyond them: strongest where the RSI is highest.
fill("rsi", 50, { gradient: { top: 70, bottom: 50, topColor: "#a78bfa", bottomColor: null }, opacity: 0.6 });
// A swing high is confirmed two bars after it, on the bar that reads it.
const swing = pivot_high(high, 2, 2);
onBar = () => {
if (fast.now === null || slow.now === null) return;
// The colour of the cloud on this bar.
fill("fast", "slow", { color: fast.now > slow.now ? "rgba(34, 197, 94, 0.25)" : "rgba(239, 68, 68, 0.25)", opacity: 1 });
// Drawn two bars back, over the high it marks; it still belongs to this bar.
if (swing.now !== null) mark("", { shape: "triangleDown", position: "above", offset: -2 });
};An average green while it rises and red while it falls, with the candles teal above it and orange below:
config = { kind: "indicator", name: "Trend colours", overlay: true };
const fast = ema(close, 20);
onBar = () => {
if (fast.now === null || fast.ago(1) === null) return;
// The line: green while the average rises, red while it falls.
const rising = fast.now >= fast.ago(1)!;
plot("ema 20", fast.now, { color: rising ? "#22c55e" : "#ef4444", width: 2 });
// The candles: teal above the average, orange below it.
barcolor(close.now! >= fast.now ? "teal" : "orange");
};A histogram in four colours, and its pane shaded while it is below zero:
config = { kind: "indicator", name: "MACD histogram" };
const hist = macd_hist(close, 12, 26, 9);
hline(0, { pane: "macd", style: "dotted" });
onBar = () => {
if (hist.now === null || hist.ago(1) === null) return;
const h = hist.now;
const growing = Math.abs(h) >= Math.abs(hist.ago(1)!);
const color = h >= 0 ? (growing ? "#16a34a" : "#86efac") : growing ? "#dc2626" : "#fca5a5";
plot("hist", h, { pane: "macd", style: "histogram", color });
bgcolor(h >= 0 ? null : "rgba(220, 38, 38, 0.06)", { pane: "macd" });
};Drawings that move
drawLine, drawBox, drawPolyline and label answer a handle. Keep it, and on a later bar handle.set({ ... }) changes the fields you name, placed from the bar set is called on, and handle.remove() takes the drawing off. The chart draws each drawing's last state, so a range that grows all day is ONE box, moved on every bar, not one box per bar. Making a drawing, set() and remove() work inside onBar only: each throws at the top of the file. A field set() does not take throws, and set() does nothing once the drawing is gone. A handle cannot be read back.
| Call | set() takes |
|---|---|
| drawLine(from, to, options) | from, to, color, width, style, extend |
| drawBox(from, to, options) | from, to, color, width, style, extend, fill |
| drawPolyline(points, { color?, width?, style?, closed?, fill? }) | points (the whole new list, 2 to 100), color, width, style, closed, fill. closed joins it back to the first point; fill shades a closed one. |
| label(text, { at?, color?, textColor?, style? }) | text, at, color, textColor, style. at is this bar's close unless you say; style is where it points: "down" (the default) sits above the point, "up" below it, "left" and "right" beside it, "none" on it. 80 characters. |
- A point is { ago: k, price } or { time, price } on this bar or an earlier one, or { ahead: n, price }: n bars to the right of this one, up to 500. A point ahead is a PROJECTION, drawn there and never data: nothing a script reads comes from it.
- plot(x, { offset: n }) moves a plot's drawing n bars to the right, or left when negative, as Pine's offset does. Only the picture moves: the values stay on their own bars. A value whose bar would be past the last bar is drawn there, to the right of the data (the leading Ichimoku cloud), and one before the first bar is not drawn. A fill follows its plots' offset, past the last bar too, and so does a fill's colour per bar.
- mark(text, { offset: n }) does the same for a mark, as Pine's plotshape(offset) does: a pivot confirmed two bars after it is marked on its own bar with mark("", { shape: "triangleDown", position: "above", offset: -2 }), above THAT bar's high. The mark still belongs to the bar that made it. A mark is drawn on the chart's bars only, so one offset past the last bar or before the first is not drawn.
- table.grid(cells, { position?, header? }) draws a grid in a corner: rows of cells, each a string, a number, null or { text, color, bg, decimals }. 20 rows by 8 columns at most and 40 characters a cell; header: true makes the first row bold. A grid shows what its LAST call said, and a corner holds one table or one grid, the later call taking it. A table and a grid are drawn in the pane the indicator belongs to: the price pane for an overlay (config.overlay: true), even when it also draws in a pane of its own, otherwise its first pane below the chart. Say { pane: "price" } or the name of a pane to put one somewhere else.
- The cap is 500 of each kind (lines, boxes, polylines and labels), the most recently made kept and the rest counted. A removed drawing frees its place.
config = { kind: "indicator", name: "Day range", overlay: true };
const first = isFirstBarOf("day");
const top = sessionHigh("day");
const bottom = sessionLow("day");
plot(ema(close, 50), { name: "ema 50", color: "#94a3b8" });
let start = 0;
let box: DrawingHandle | null = null;
let tag: DrawingHandle | null = null;
onBar = (ctx) => {
if (top.now === null || bottom.now === null) return;
if (first.now === 1 || box === null || tag === null) {
// A new day: one box and one label, moved on every later bar of it.
start = ctx.bar.time;
box = drawBox({ time: start, price: top.now }, { ago: 0, price: bottom.now }, { color: "#60a5fa", fill: "rgba(96, 165, 250, 0.08)" });
tag = label("", { at: { ago: 0, price: top.now } });
}
box.set({ from: { time: start, price: top.now }, to: { ago: 0, price: bottom.now } });
tag.set({ text: "range " + (top.now - bottom.now).toFixed(5), at: { ago: 0, price: top.now } });
if (barState.isLast) {
// A projection, ten bars past the last one: drawn, never read.
drawLine({ ago: 0, price: close.now! }, { ahead: 10, price: close.now! }, { style: "dashed" });
drawPolyline([{ ago: 20, price: low.ago(20)! }, { ago: 10, price: high.ago(10)! }, { ago: 0, price: close.now! }], { color: "#f59e0b" });
table.grid([["", "High", "Low"], ["Today", top.now, bottom.now]], { position: "topLeft", header: true });
}
};Indicators
An indicator is a file that draws and does not trade. config = { kind: "indicator", name } makes one. It runs over the research chart's own bars, for whatever symbol and timeframe the chart shows, and is run again when they change, so it leaves symbol and interval out. Everything on this page that reads or draws works in it the same way.
- onBar is optional. A file of top-level plot() calls is complete, and a file that never sets onBar at all is read as an indicator even without config.kind.
- When there is an onBar it runs on every bar, after every series(fn), and is handed ctx.bar and ctx.index. ctx.buy, ctx.sell, ctx.close, ctx.modify and ctx.reduce throw: "An indicator does not trade. Remove config.kind to make this a strategy."
- overlay: true says it belongs on the price. A plot's own pane still wins.
- input() works as it does in a strategy, so the panel can change a length and redraw.
- An indicator cannot be backtested or sent live, and a strategy cannot be added to the chart as an indicator: each is refused with a message saying which it is.
config = { kind: "indicator", name: "Two averages", overlay: true };
plot(ema(close, 20), { name: "fast" });
plot(ema(close, 50), { name: "slow", width: 2 });
fill("fast", "slow", { opacity: 0.1 });With onBar, an indicator shades, colours and marks bar by bar, and can keep state of its own between bars:
config = { kind: "indicator", name: "RSI with zones" };
const len = input("len", 14, { min: 5, max: 50 });
const r = rsi(close, len);
plot(r, { name: "rsi", pane: "rsi", color: "#a78bfa", width: 2 });
hline(70, { pane: "rsi", label: "overbought" });
hline(30, { pane: "rsi", label: "oversold" });
fill("rsi", 50, { color: "#a78bfa", opacity: 0.1 });
let stretch = 0;
onBar = (ctx) => {
if (!r.ready) return;
stretch = r.now > 70 || r.now < 30 ? stretch + 1 : 0;
bgcolor(r.now > 70 ? "rgba(251, 113, 133, 0.15)" : r.now < 30 ? "rgba(52, 211, 153, 0.15)" : null, { pane: "rsi" });
if (stretch === 5) barcolor("#facc15");
if (crossAbove(r, 30).now) mark("out of oversold", { shape: "arrowUp", position: "below" });
};Indicators on the chart. When the file in the Builder is an indicator, a saved one can be added to the live chart with "Add to live chart", under its config.name; the live chart asks before it runs the code for the first time. The values the preview's inputs are set to go with it.
- It runs in a worker the page can stop, over the bars the chart already holds, for the symbol and timeframe the chart shows. Change either and it runs again there. A run that takes longer than 10 seconds is stopped and says so.
- It is redrawn when a new bar arrives, not on every live tick in between, so the last bar's values settle at its close.
- It is kept with the thread, like any indicator on the chart, and removed from the chart's legend like one. Removing it keeps a copy in your library.
- A failure is shown on the chart as a problem naming the indicator and the bar, not a blank chart.
- Saved to your indicators, it is also in the Indicators menu of the live terminal, backtesting and arena charts, marked TypeScript script. There it runs in the same worker over that chart's own bars, again on each new bar rather than on every tick, and in a replay only over the bars already revealed. In the arena a script may read nothing but those bars: one that reads another market, another timeframe, the calendar or COT says so and draws nothing, because the arena's bars carry no real date to ask for. A script not written or run in this browser waits for you to press Run it. Rules backtests and going live read an indicator's plot expressions, which a script does not have, so they refuse it. A script alerts through its own alert() calls instead: see Alerts.
Saved indicators
indicator(name, inputs?) reads one of YOUR saved indicators inside a strategy, its plots as series: an array in the order it plots them, hidden plots included, and each also by its plot name when that name is not a number or a word an array already has. const [basis, upper, lower] = indicator("My Bands", { length: 20 }), or indicator("My Bands").upper.
- Only your own saved TypeScript indicators: never a draft, never another user's and never a shared one. A name matches case-insensitively; an id (lib_...) names that item and survives a rename. Two saved indicators with one name are refused as ambiguous, naming their ids, so you write the id.
- The name and every input are written out as plain values. Inputs override the indicator's input() defaults by name, and a name it has no input for, or a value of the wrong kind, is refused rather than ignored.
- It runs on its own, BEFORE the strategy, over the same bars, and only its plot values reach the strategy, so a value at a bar never comes from a later one. Its source never enters the strategy: not its scope, not its debugger, and not an error message, which says only that the indicator failed and on which bar.
- Its trust is its own. It runs on the fast path only when you agreed to run exactly its text, in its own Builder or by adding it to a chart; Check on the strategy does not cover it, and an edit after that consent makes it text nobody agreed to, so it runs in the sandbox.
- Live, the version kept when the strategy went live runs. Deleting, renaming or editing the saved indicator afterwards neither stops nor changes a live strategy that uses it; stop the strategy and take it live again to run the edit.
- A protected item, one of the AnalyticsFX indicators, runs only for an admin; everyone else is refused as if it were not theirs.
- Strategies only: not in an indicator, a tf() or sym() builder, or an alert. At most 4 per run.
config = { name: "Trade my bands", symbol: "EURUSD", interval: "1H" };
// "My Bands" is one of the user's OWN saved indicators, named exactly as the library lists it.
const [basis, upper, lower] = indicator("My Bands", { length: 20 });
onBar = (ctx) => {
if (basis.now === null || upper.now === null || lower.now === null) return;
if (ctx.flat && close.now! < lower.now) ctx.buy({ risk: 0.5, stop: (upper.now - lower.now) / 2 });
else if (ctx.long && close.now! > basis.now) ctx.close();
};Alerts
alert("message", { key?, once?, freq?, data? }) inside onBar raises an alert on the bar being decided, from an indicator or a strategy. In the browser it is only shown: each one is drawn on the chart as a mark on the bar that raised it, and the run panel of a strategy lists them, so you can see where it would have fired before you ask for anything. At the top of the file it throws, as mark() does, because there is no bar there to alert on.
| Option | What it does |
|---|---|
| key | What groups repeats of one alert, up to 40 characters. The message unless you say. The same key twice on one bar is one alert. |
| once | true alerts only on the first of consecutive bars that call it, so a condition that stays true for a week alerts once. Worked out from the bars alone, so the chart and the email agree. The same as freq: "once". |
| freq | "perBar" (the default) once a bar; "once" only on the first of consecutive bars; "onceAcrossRun" only the first time in the run, which live means in the runner's window of recent bars. |
| data | Up to 10 named values, each a number, true or false, or a string of up to 200 characters, and 1,000 characters in all as JSON, sent with the alert. More is refused, not cut. |
The message may carry {{symbol}}, {{interval}}, {{close}} and {{time}} (the bar's open time, ISO in UTC), which are filled in. Any other {{name}} stays as written. A message is cut at 200 characters, and one bar raises 5 alerts at most.
data reaches each channel in its own form: the email adds a sentence such as Data: rsi = 28.4; atrPips = 12. Telegram gets a line per value, name: value. Discord gets the same lines in a code block. A webhook gets the values as JSON under its own data key, so no name in them can stand in for a field of the alert. A script never says where an alert goes: there is no URL or channel in alert(), only the channels in your alert settings.
config = { kind: "indicator", name: "RSI watch", overlay: true };
const r = rsi(close, 14);
const vol = atr(14);
onBar = (ctx) => {
if (!r.ready || !vol.ready) return;
const atrPips = vol.now! / symbol.pip;
if (r.ago(1)! >= 30 && r.now! < 30) {
alert("{{symbol}} RSI under 30 at {{close}}", { once: true, data: { rsi: Math.round(r.now! * 10) / 10, atrPips: Math.round(atrPips) } });
}
// Only the last bar draws the readout: the same on a backtest chart and live.
if (barState.isLast) {
table.grid([["RSI", r.now!], ["ATR (pips)", atrPips]], { position: "topRight" });
label(r.now! < 30 ? "oversold" : "watching", { at: { ago: 0, price: high.now! }, style: "down" });
}
};Alert me, in the Builder for any file that calls alert() and in the chart's Alerts panel for a script indicator, turns delivery on. Pick the market and the timeframe (an indicator uses the chart's unless you choose another, a strategy its own config) and, if you like, a cooldown. From then on the server runs a copy of the file, with the inputs the panel had, over the latest 1,500 closed bars every time one closes, and sends each alert the bar that has just closed raised to your email and the channels in your alert settings. Each is sent once: a late run does not send it twice, and a cooldown holds back everything until it has passed since the last email.
- It runs in the same WebAssembly sandbox as a live strategy, with its time and memory limits: 5 seconds a run.
- Everything it reads besides its own bars has to be written out, as for going live: tf("4H", ...), sym("XAUUSD", ...), surprise("USD"), cot("XAUUSD"). The server loads those by reading the source before it runs, and a market it could not read there would be an alert that silently never fires, so Alert me refuses the file and says which call.
- A portfolio alerts too, on its own market's bars: the server loads the closed bars of every market in config.markets up to the same close, as a live portfolio does, so those markets have to be written out. Alert me files it under the portfolio's own market and timeframe.
- The alert keeps the file as it was when you turned it on. Edit it later and the alert still runs the old copy; turn it on again for the new one and delete the old.
- 5 alerts per bar at most, a message of up to 200 characters, 5M to 1W bars.
config = { kind: "indicator", name: "RSI alerts" };
const len = input("len", 14, { min: 5, max: 50 });
const r = rsi(close, len);
plot(r, { name: "rsi", pane: "rsi" });
hline(30, { pane: "rsi" });
hline(70, { pane: "rsi" });
onBar = (ctx) => {
if (!r.ready) return;
if (crossAbove(r, 30).now) alert("{{symbol}} RSI back above 30 at {{close}}", { data: { rsi: Math.round(r.now * 10) / 10 } });
if (r.now > 70) alert("{{symbol}} {{interval}} RSI overbought", { key: "ob", freq: "once" });
if (r.now < 20) alert("RSI under 20 for the first time in this run", { freq: "onceAcrossRun" });
};ctx
onBar is called once per bar, in order, at the bar's close, and is handed a frozen context. What it decides is acted on at the next bar's open. The cursor is set to the bar that has just closed and never to the bar about to be traded: setting it forward would hand the strategy the close of the bar it is about to trade on, which is the whole of look-ahead in one line.
| Member | Meaning |
|---|---|
| ctx.bar | { time, open, high, low, close } for the bar being decided. Frozen. time is the bar's open time in epoch milliseconds. |
| ctx.index | How many bars have been decided so far, from 0. |
| ctx.lastIndex | The index of the run's last bar, known before the first. For drawing, not trading: see Bar state and time. |
| ctx.balance | The account balance as the run has it now. |
| ctx.trades | How many positions have been opened in this run. |
| ctx.equity, ctx.peak, ctx.drawdown | Equity marked at this bar's close, its high, and the fraction it is below that high. |
| ctx.closedTrades, ctx.wins, ctx.losses | The record so far. A position that took partial exits counts once. |
| ctx.streak | Results in a row: 3 is three wins, -2 is two losses, 0 before any. |
| ctx.lastTrade | { side, entry, exit, pnl, r, bars, reason, time } of the last position to close, or null. |
| ctx.history | Every position closed so far, partial exits folded into one, oldest first, the latest 500. Frozen. Each has lastTrade's fields plus entryTime, size (the lots it opened with), commission, swap, mae and mfe (the furthest price went against it and in its favour, in price) and maeR and mfeR (the same in multiples of its opening stop, or null). See The record. |
| ctx.orders, ctx.pending | Entry orders still waiting to fill, and how many. See Waiting orders. |
| ctx.cancel(idOrTag?) | Withdraw a waiting entry order. One market only. See Waiting orders. |
| ctx.flat, ctx.long, ctx.short | What is held. |
| ctx.position | { side, entry, size, bars, risk, r, stopPrice, targetPrice, pnl } or null. risk is the stop distance it opened with, in price; r is how far price has gone in favour, in multiples of that; stopPrice and targetPrice are where they sit now; pnl is the open profit; mae and mfe are the furthest price has gone against it and in its favour since it filled, in price. mae and mfe are read from the high and low of every bar it was held through, the bar it filled on whole: an approximation, which the bar magnifier does not refine. |
| ctx.buy(order), ctx.sell(order) | Open a position with this risk. |
| ctx.close() | Close whatever is held, at the next bar's open. |
| ctx.modify({ stopPrice?, targetPrice? }) | Move the held position's stop or target, as price levels, at the next open. null removes the target. |
| ctx.reduce(percent) | Close that percent of the held position at the next open. |
One decision per bar: buy, sell or close, once, and not together with modify or reduce. modify and reduce may both happen on one bar. A second call on the same bar throws, with one exception: close() then a buy or sell of the other side closes by the exit rule and reverses at the same open. buy() is a standing instruction, meaning be long until told otherwise, so calling it on every bar of a trend opens one trade and not twenty. There is one position at a time.
What the strategy is told is what the engine actually holds, not what the strategy asked for. After a stop-out it is told it is flat and decides again, which is why the usual shape is if (ctx.flat && ...) ctx.buy(...). minHold blocks an exit or a reversal until the position has been held that long, and maxTrades stops new entries once the count is reached.
The record
ctx.history is every position closed so far, oldest first, partial exits folded into one, frozen. It keeps the latest 500, so a rule over the last few positions reads ctx.history.slice(-10). Each entry has what ctx.lastTrade has, and more.
| Field | Meaning |
|---|---|
| side, entry, exit, pnl, bars, reason, time, tag | As ctx.lastTrade: pnl in account money with every partial and cost included, reason such as "stop", "target" or "exit rule", time when it closed. |
| r | The final exit's distance from the entry in multiples of the opening stop, or null without a stop. |
| entryTime, size | When it filled, epoch milliseconds, and the lots it opened with. |
| commission, swap | What it paid in commission, and the swap it was paid (positive) or charged (negative), in account money. |
| mae, mfe | The furthest price went against it and in its favour, in price. |
| maeR, mfeR | The same in multiples of its opening stop, or null without one. |
The excursions are read from the high and low of every bar the position was held through, so they are an approximation; the bar magnifier refines exits, not this. ctx.position carries mae and mfe too, while it is open.
config = { name: "Stand down after giving back", symbol: "EURUSD", interval: "1H" };
const fast = ema(close, 20);
const slow = ema(close, 50);
const vol = atr(14);
onBar = (ctx) => {
if (!slow.ready || !vol.ready) return;
// Of the last ten positions, how many were 1R in profit and still lost?
const recent = ctx.history.slice(-10);
const gaveBack = recent.filter((t) => (t.mfeR ?? 0) >= 1 && t.pnl < 0).length;
metric("gave back", gaveBack);
if (ctx.flat && gaveBack < 3 && crossAbove(fast, slow).now) ctx.buy({ lots: 0.5, stop: 2 * vol.now!, target: 3 * vol.now! });
else if (ctx.long && crossBelow(fast, slow).now) ctx.close();
};Orders
Every distance in an order is in PRICE, not pips and not a price level. A stop of 2 * vol.now is two ATRs of distance from the entry.
| Field | Meaning |
|---|---|
| lots | Size in lots, flat. |
| risk | Percent of balance lost if the stop is hit. Needs a stop. |
| riskMoney | Account money lost if the stop is hit. Needs a stop. |
| margin | Percent of balance committed as margin, or { usd: 2000 }: that much account money committed as margin. |
| stop | The stop distance in price, or { fraction: 0.004 }: that fraction of the fill price. |
| target | The target distance in price, or { fraction: 0.008 } of the fill price. |
| stopPrice | The stop as an exact price level instead of a distance, such as a swing low. |
| targetPrice | The target as an exact price level. |
| breakeven | Move the stop to entry once price is this many stop multiples in favour. Needs a stop. |
| takes | [{ at, percent }]. at is in stop multiples, percent is above 0 and below 100. Needs a stop. |
| trail | { distance, fromR }: distance is in price, fixed at the entry, and needs a stop; fromR is in stop multiples. Or { atr: 2, period: 14 }, that many ATR (over real bars) behind the close, { percent: 0.5 } of the close, or { fraction: 0.005 }, each measured again at every bar; those three are one market only. fromR needs a stop. |
| entry | { type: 'limit' | 'stop', offset, expires }. limit waits for a pullback of offset, stop for a breakout of it. offset is in price. expires is the order's time in force: see Order types and tags. Or { type: 'stopLimit', offset, limitOffset, expires }: see Stop-limit entries. |
| hold | Bars. Close the position after this many, whatever else happens. |
| tag | A label, such as 'breakout', of 1 to 40 characters. The trade carries it into the results, the trade list, the chart and ctx.lastTrade.tag, and the run is also shown per tag. |
| oco | A portfolio only. A one-cancels-other group name for an order with entry: see Order types and tags. |
A size is one of lots, risk, riskMoney or margin, never two. With none at all the order is one lot. risk and riskMoney are worked out from the stop, so they require one, and so do breakeven, takes and trail, which are measured in multiples of it.
Every number in an order must be above zero. Zero and null are refused with a hint, because they are almost always an indicator with no reading yet. Passing the series itself rather than its reading is the commonest mistake there is, and the message says so: read the series at this bar with .now.
trail.distance is a number, not a series: it is the distance the position keeps, decided at the entry, and it does not widen with the market by itself. Passing undefined for a field is the same as leaving it out, which makes target: quiet ? undefined : 4 * vol.now a legitimate way to write a conditional field.
Order types and tags
An order with entry waits away from the price instead of filling at the next open. How long it waits is its time in force, written as entry.expires.
| expires | The order lapses |
|---|---|
| left out, or 'gtc' | Never by itself: it waits until it fills, the strategy withdraws it, or (on one market) the strategy stops asking for that side. |
| a number | After that many bars, counting the bar it is placed on. 1 is the placed bar only. |
| 'day' | At the first bar that opens on a later UTC calendar day than the bar it was placed on. On daily bars that is the next bar. |
| 'ioc' | Fill on the bar it is placed on, or not at all. The same as 1, and cancelled live at that bar's close. |
An order with entry is a bracket when it also carries a stop, a target, takes or breakeven: they are measured from where it FILLS, not from the close it was decided at, so stop: 0.0005 on a buy stop that fills at 1.1010 places the stop at 1.1005. On the bar it fills, its stop may close it (price had to pass through the entry to reach a stop beyond it) but its target is not taken and break-even is not armed, because the bar's high may have printed before the order filled.
config = { name: "Breakout bracket", symbol: "EURUSD", interval: "1H", balance: 10000, leverage: 30 };
const top = highest(high, 20);
const vol = atr(14);
onBar = (ctx) => {
if (!top.ready || !vol.ready) return;
// A buy stop just above the 20-bar high, for today only, with its bracket.
if (ctx.flat) {
ctx.buy({
lots: 0.5,
entry: { type: "stop", offset: top.now - close.now + vol.now * 0.1, expires: "day" },
stop: 1.5 * vol.now,
target: 3 * vol.now,
tag: "range break",
});
}
};Adding to a position needs the portfolio runner, because a one-market strategy holds one position and buy() while long means stay long. Set markets: [] for a portfolio on this market alone, and config.maxEntries for the most positions it may hold in one direction, entry orders still waiting included. Each add is its own position: its own fill, stop, target and management, and its own trade in every count. A partial exit of an add folds into that add, as it does for any position. An add past maxEntries is ignored, like a buy() while long is on one market. Note that in a portfolio sell() does not reverse a long, it opens a short beside it, so close first when a reversal is what you mean.
config = { name: "Pyramid", symbol: "EURUSD", interval: "1H", balance: 10000, leverage: 30, markets: [], maxEntries: 3 };
const trend = ema(close, 50);
const vol = atr(14);
onBar = (ctx) => {
if (!trend.ready || !vol.ready) return;
const longs = ctx.positions.filter((p) => p.side === "long");
// The first entry on the trend, then one add each time the newest is a stop in profit.
if (longs.length === 0 && close.now > trend.now) ctx.buy({ lots: 0.2, stop: 2 * vol.now, tag: "first" });
else if (longs.length > 0 && longs[longs.length - 1].r !== null && longs[longs.length - 1].r > 1) {
ctx.buy({ lots: 0.1, stop: 2 * vol.now, tag: "add" });
}
if (longs.length > 0 && close.now < trend.now) ctx.close();
};oco: 'name' makes entry orders one-cancels-other: when one fills, every other order waiting in the same group on the same market is withdrawn. It needs the portfolio runner too, because only a portfolio holds two orders at once. When one bar reaches both legs, one OHLC cannot say which came first, so the leg whose level is nearer the bar's open fills and the other is cancelled (a tie goes to the order placed first). That is an assumption: with config.magnify the bar is replayed through its finer bars and the leg they reach first fills. oco goes live: see Going live for how the forward account holds a group to one fill.
config = { name: "Range straddle", symbol: "EURUSD", interval: "1H", balance: 10000, leverage: 30, markets: [], magnify: true };
const top = highest(high, 12);
const bottom = lowest(low, 12);
onBar = (ctx) => {
if (!top.ready || !bottom.ready) return;
if (ctx.positions.length === 0 && ctx.orders.length === 0) {
const width = top.now - bottom.now;
// An offset has to be above zero, and a close can sit exactly on the range's edge.
const up = Math.max(top.now - close.now, 0.0001);
const down = Math.max(close.now - bottom.now, 0.0001);
ctx.buy({ lots: 0.5, entry: { type: "stop", offset: up, expires: 12 }, stop: width, target: width, oco: "range", tag: "up" });
ctx.sell({ lots: 0.5, entry: { type: "stop", offset: down, expires: 12 }, stop: width, target: width, oco: "range", tag: "down" });
}
};tag labels a trade. The results then add a table per tag (positions, net, win rate and profit factor, positions without a tag listed as untagged), the trade list shows each trade's tag, and ctx.lastTrade.tag says which setup the last trade was. Live, the tag is written onto the forward trade as its confluences, which the forward account's analytics group by.
Waiting orders
An entry order that has not filled yet is on ctx.orders, frozen: { id, symbol, side, level, size, bars }, where level is where it fills, spread and slippage included, and bars how long it has waited, 0 on the bar it went in. On one market each also has its type, its tag, and for a stopLimit its limit and whether a bar has triggered it. ctx.pending is how many there are. One market holds none or one.
| Call | What it does |
|---|---|
| ctx.cancel() | Nothing held: withdraws the waiting entry order and stops wanting that side, so nothing reopens it. Throws while a position is held: guard it with ctx.flat, and use ctx.close() to exit. |
| ctx.cancel(id) | Withdraws the waiting order with that id, from ctx.orders. Does nothing when none has it. |
| ctx.cancel("tag") | Withdraws the waiting order placed with that tag. Does nothing when none has it. |
- A cancel that withdraws something is the bar's one decision. Replacing a waiting order therefore takes two bars: cancel on one, buy or sell on the next.
- ctx.cancel is one market only. A portfolio refuses it: there, ctx.close(order.id) withdraws one, and a filled one-cancels-other leg withdraws its partner.
config = { name: "Limit that follows the average", symbol: "EURUSD", interval: "1H" };
const mid = ema(close, 20);
const vol = atr(14);
onBar = (ctx) => {
if (!mid.ready || !vol.ready) return;
const waiting = ctx.orders[0];
// A limit that has drifted more than an ATR from the average is withdrawn on this bar,
// which is this bar's one decision, and placed again on a later one.
if (waiting && Math.abs(waiting.level - mid.now!) > vol.now!) {
ctx.cancel(waiting.id);
return;
}
if (ctx.flat && ctx.pending === 0 && close.now! > mid.now!) {
ctx.buy({ lots: 0.5, entry: { type: "limit", offset: close.now! - mid.now!, expires: 24 }, stop: 2 * vol.now!, tag: "dip" });
}
};By tag, with onFill and onExit and the record:
config = { name: "Pullback limit", symbol: "EURUSD", interval: "1H" };
const vol = atr(14);
const mid = ema(close, 50);
onFill = (ctx, fill) => { print("filled", fill.tag, fill.price); };
onExit = (ctx, trade) => { if ((trade.mfeR ?? 0) > 1 && trade.pnl < 0) print("gave back a 1R winner", trade.time); };
onBar = (ctx) => {
if (!vol.ready || !mid.ready) return;
const recent = ctx.history.slice(-10);
metric("hit rate, last 10", recent.length ? recent.filter((t) => t.pnl > 0).length / recent.length : 0);
const waiting = ctx.orders.find((o) => o.tag === "pullback");
if (waiting && close.now! < mid.now!) {
ctx.cancel("pullback");
} else if (ctx.flat && !waiting && close.now! > mid.now!) {
// A limit half an ATR below, for the rest of the UTC day. (A stopLimit entry runs in backtests only.)
ctx.buy({ risk: 0.5, stop: 2 * vol.now!, target: 3 * vol.now!, entry: { type: "limit", offset: 0.5 * vol.now!, expires: "day" }, tag: "pullback" });
}
};onFill and onExit
Two optional handlers are told what happened during a bar. onFill = (ctx, fill) => { ... } is told every entry that filled, at market or by an entry order: { id, side, type, price, time, size, tag }, where type is market, limit, stop or stopLimit, price includes spread and slippage, and time is the open time of the bar it filled on. onExit = (ctx, trade) => { ... } is told every position that closed whole, its partial exits folded in, as ctx.history has it, with its reason.
- Both are told at the CLOSE of the bar it happened on, after the series are written and before onBar, with the same ctx.
- Within a bar they come in the order the engine booked them: a fill before the stop that closed it on the same bar, and a reversal's exit before its new fill.
- Still one decision per bar across onFill, onExit and onBar together.
- Live, these are the replayed run's own fills and exits, not the forward account's.
Filling at the close
config.fill says when what a bar decides fills. "nextOpen", the default, fills at the next bar's open. "close" fills at the decided bar's own close, Pine's process_orders_on_close: market entries, closes, reductions and stop or target moves.
- It is OPTIMISTIC, and the results say so: a live order can only go in once that bar has closed. Live runs send the order at the close either way, so this changes only what the backtest assumes.
- Entry orders that wait (limit, stop, stopLimit) are placed at that close either way.
- Not over a weekend gap with closeBeforeWeekend, where the order waits for the next open.
- A fill at a close is stamped with that bar's time, as every close-price fill is.
- One market only.
config = { name: "Filled at the close", symbol: "EURUSD", interval: "1H", fill: "close" };
const fast = ema(close, 10);
const slow = ema(close, 30);
const vol = atr(14);
onBar = (ctx) => {
if (!slow.ready || !vol.ready) return;
// Filled at this bar's own close: optimistic, since a live order goes in only after it.
if (ctx.flat && crossAbove(fast, slow).now) ctx.buy({ lots: 0.5, stop: 2 * vol.now! });
else if (ctx.long && crossBelow(fast, slow).now) ctx.close();
};Stop-limit entries
entry: { type: "stopLimit", offset, limitOffset, expires? } is a stop at offset that, once a bar reaches it, fills at no worse than limitOffset beyond it: at the trigger, or at the open when the bar opened past the trigger but within the limit. A bar that opens past the limit too fills nothing at its open: the order then rests as a limit at the limit price, filling on the first bar, that one included, that trades back to it, and stays pending until it fills, lapses or is withdrawn. Its stop, target, partials and break-even are measured from where it fills.
One market only, and backtests only: Go live refuses a strategy with a stop-limit entry for now, because a forward account has no stop-limit order.
config = { name: "Breakout with a limit", symbol: "EURUSD", interval: "1H" };
const top = highest(high, 20);
const vol = atr(14);
onBar = (ctx) => {
if (!top.ready || !vol.ready || !ctx.flat || ctx.pending > 0) return;
const trigger = Math.max(top.now! - close.now!, 0.1 * vol.now!);
// Triggered above the 20-bar high, filled no worse than a fifth of an ATR past the trigger.
ctx.buy({ lots: 0.5, entry: { type: "stopLimit", offset: trigger, limitOffset: 0.2 * vol.now!, expires: 10 }, stop: 2 * vol.now!, tag: "break" });
};Risk limits
config.risk = { maxDailyLoss, maxConsecutiveLosses, maxLots, dayTz, allow } stops the strategy opening positions while it runs. Each limit refuses NEW entries only: a close, a reduce or a modify always goes through, and nothing is closed for you. Use it rather than code that tracks the day's equity or the streak itself, because it is the same rule in a backtest and live.
| Field | Meaning |
|---|---|
| maxDailyLoss | A FRACTION of the equity the day started with: 0.03 stops new entries for the rest of the day once equity, open profit included at bar closes, is three percent below it. |
| maxConsecutiveLosses | Losing positions in a row, partial exits folded in. Once reached, no new entry for the rest of that day. A win resets the count. |
| maxLots | The most lots one entry may take. An entry sized above it is refused at the fill, never made smaller. Live, the order is held to it too. |
| dayTz | The time zone whose midnight starts a day, such as "America/New_York". "UTC" unless set. A decision belongs to the day its bar closes in. |
| allow | "long" or "short" refuses entries on the other side, and a reversal into it only closes. "both" unless set. |
- The limits act on the run's own account: its balance and equity. Live runs replay the same bars, with the same conversion bars on a cross, so they refuse the same entries on the same day boundary. The live runner replays a sliding window of the latest 1,500 bars, so live the day's starting equity and the losing streak are those of that window's account.
- In forward mode the real forward account is checked as well, before each new entry: once its own loss for the day (trades closed today plus open positions) reaches maxDailyLoss, the entry is refused even though the replay allowed it, and the row says why. maxConsecutiveLosses is checked there as well: the positions this live strategy itself has closed on the forward account are counted back from the latest (a partial exit belongs to its position), and at the limit no new entry is opened for the rest of that day. A win resets the count. Trades opened before this check existed carry no record of their strategy and do not count, and neither do trades you placed by hand. Closes always go through.
- An entry order still waiting when a limit fires is withdrawn. In a portfolio, an open decided before a limit fires, on a market that has no bar yet to fill it on, still fills when that market's bar arrives, in the backtest and live alike.
- The debugger names the limit that refused an entry.
config = {
name: "RSI reversion with limits",
symbol: "EURUSD",
interval: "1H",
// No new entry for the rest of the day after a 2% loss or 3 losers in a row.
risk: { maxDailyLoss: 0.02, maxConsecutiveLosses: 3, maxLots: 5, dayTz: "Europe/London" },
};
const r = rsi(close, 14);
const vol = atr(14);
onBar = (ctx) => {
if (!r.ready || !vol.ready) return;
if (ctx.flat && r.now! < 30) ctx.buy({ risk: 0.5, stop: 2 * vol.now!, target: 3 * vol.now! });
else if (ctx.long && r.now! > 55) ctx.close();
};Commission
| config.commission | What is charged |
|---|---|
| 7, or { perLot: 7 } | Account money per lot, round turn, charged when the position, or a partial of it, closes. |
| { perOrder: 2 } | Account money per fill: the entry and every exit, a partial exit included. |
| { percent: 0.05 } | A percent of each fill's value, entry plus exit (lots times contract size times price, in account money): 0.05 is five hundredths of a percent. Both legs are converted to account money at the rate of the bar the position closes on, the entry leg included. |
Every form is booked when the position closes. Live on a forward account, config.commission is not what is charged: the account's own commission setting is, and it is none unless the account sets one. To make a forward account's results match the backtest, set its commission to the same.
Managing the position
An order sets its risk at the entry. After that, three things change the position you hold, all acted on at the next open like every other decision.
- ctx.modify({ stopPrice, targetPrice }) moves the stop or the target to a price LEVEL. targetPrice: null removes the target. A stop cannot be removed, only moved. A moved stop takes over from break-even, which would otherwise drag it back to the entry.
- ctx.reduce(percent) closes that share at the next open, booked as a partial exit. The rest stays open as the same position, and every panel folds the two into one trade.
- stopPrice and targetPrice on buy() and sell() place the levels exactly, rather than at a distance from a fill you cannot know yet. risk sizing works from them.
A level on the wrong side of the price is refused when you ask for it, with a message saying which way it goes. It can also turn wrong between the close you decide at and the next open, when the market gaps: the engine checks again at the open and refuses it as a broker would, and the run's warnings count how many times.
The account's own record is on ctx: equity, peak and drawdown, the wins, losses and streak, and lastTrade. A position that took partial exits is one trade there, its pnl the sum of all of them, which is what "did the last trade win" means.
config = { name: "Managed swing", symbol: "EURUSD", interval: "1H", balance: 10000, leverage: 30 };
const swingLow = lowest(low, 10);
const trend = ema(close, 50);
const vol = atr(14);
onBar = (ctx) => {
if (!trend.ready || !vol.ready || !swingLow.ready) return;
// Stand aside after three losses in a row, or ten percent down from the high.
if (ctx.flat && (ctx.streak <= -3 || ctx.drawdown > 0.1)) return;
if (ctx.flat && close.now > trend.now) {
const size = ctx.lastTrade && ctx.lastTrade.pnl < 0 ? 0.25 : 0.5;
ctx.buy({ lots: size, stopPrice: swingLow.now - vol.now, targetPrice: close.now + 4 * vol.now });
return;
}
const p = ctx.position;
if (!p || p.r === null) return;
// Half off at 1.5R, then the stop to the entry once the rest is 2R up.
if (p.r > 1.5 && p.size > 0.3) ctx.reduce(50);
else if (p.r > 2 && p.stopPrice !== null && p.stopPrice < p.entry) ctx.modify({ stopPrice: p.entry });
};Inputs and optimising
A parameter declared with input() is one the platform can see. It returns its value, so it reads like any other number, and the panel lists it with a field to run the strategy with something else.
config = { name: "Two averages", symbol: "EURUSD", interval: "1H", balance: 10000, leverage: 30 };
const fastLen = input("fast", 20, { min: 10, max: 30, step: 5 });
const slowLen = input("slow", 60, { min: 40, max: 80, step: 20, label: "Slow EMA" });
const longOnly = input("longOnly", true);
const fast = ema(close, fastLen);
const slow = ema(close, slowLen);
const vol = atr(14);
onBar = (ctx) => {
if (!vol.ready || !slow.ready) return;
if (ctx.flat && crossAbove(fast, slow).now) ctx.buy({ lots: 0.5, stop: 2 * vol.now });
else if (ctx.long && crossBelow(fast, slow).now) ctx.close();
else if (!longOnly && ctx.flat && crossBelow(fast, slow).now) ctx.sell({ lots: 0.5, stop: 2 * vol.now });
};- input(name, value, { min, max, step, label }) takes a number, or true and false for a switch. The name is how an override finds it, so one name is one input: the same name declared twice with different values is refused.
- A text value is a choice or free text. input("mode", "Multi", { options: ["Simple", "Multi"], label: "Signal logic" }) returns the chosen string, and the panel shows the options as a list; the file's value has to be one of them, and a value from the panel that is not one is ignored. Without options it is a text field. Write to file writes the string back in quotes.
- Text inputs are not swept. A sweep varies numbers along a range, and a choice between modes is better compared as separate runs: pick each in the panel and run it.
- Inputs of a kind, each shown its own way in the panel and never swept: input.source("src", "close") returns that price field as a series (open, high, low, close, hl2, hlc3, ohlc4, hlcc4); input.session("win", "08:00-12:00", { tz: "Europe/London" }) returns { from, to, tz }, which inSession() takes; input.color("up", "#34d399") a hex colour; input.time("anchor", "2024-01-01") epoch milliseconds in UTC. See Input kinds for what each accepts.
- input.timeframe("htf", "4H", { options: ["1H", "4H", "1D"] }) and input.symbol("other", "XAUUSD", { options: ["XAUUSD", "XAGUSD"] }) choose what tf() and sym() read. They take their options written out, and only that way, because every option is loaded before the run: const htf = input.timeframe(...) then tf(htf, s => s.close) runs in the editor and goes live. A list built while the file runs is refused.
- The FILE's value is what goes live. A value typed into the panel changes the runs in the panel and nothing else, until you write it into the file.
- Optimise searches the inputs that have a min and a max, in steps (a quarter of the range when no step is given). The grid tries every combination up to the budget, 200 unless you change it, at most 400.
What a sweep ranks by is yours to choose. config.optimise sets it in the file, and the panel's Rank by overrides it for the panel's runs without touching the file. Whatever it is, it is read off the first 70 percent only.
| Objective | What it reads |
|---|---|
| "net" | Net of the positions that closed in the part, in account money. The default. |
| "sharpe" | Daily returns of marked equity (the last value of each UTC day), mean over standard deviation, annualised by how many days traded per year. Not a per-trade ratio. |
| "profitFactor" | Money won by winning positions over money lost by losing ones. |
| "maxDrawdown" | The deepest fall of marked equity from its high, as a fraction. Lower is better. |
| "winRate" | The share of positions that made money. |
| "returnDrawdown" | Return on the part's starting equity over its deepest drawdown. |
| { metric, direction, stat } | A metric() of your own: its "last" reading unless stat says "mean", "min", "max" or "count"; "max" unless direction says "min". |
A variant with fewer than 5 positions in the part it is ranked on goes below every variant with enough, for every objective except net and a metric: one lucky trade is a win rate of 100 percent, and a variant that never traded has no drawdown at all.
config = {
name: "Ranked by my own edge",
symbol: "EURUSD",
interval: "1H",
balance: 10000,
leverage: 30,
optimise: { metric: "edge per trade", direction: "max", stat: "last" },
};
const fastLen = input("fast", 20, { min: 10, max: 40, step: 5 });
const fast = ema(close, fastLen);
const slow = ema(close, 80);
const vol = atr(14);
onBar = (ctx) => {
if (!vol.ready || !slow.ready) return;
// Written on every bar, so its last reading is its value at the end of the run.
if (ctx.closedTrades > 0) metric("edge per trade", (ctx.equity - 10000) / ctx.closedTrades);
metric("in the market", !ctx.flat);
if (ctx.flat && crossAbove(fast, slow).now) ctx.buy({ lots: 0.5, stop: 2 * vol.now });
else if (ctx.long && crossBelow(fast, slow).now) ctx.close();
};Search is Grid, Random, or Random + refine. A grid larger than the budget runs as a random search of the budget instead, and says so. Random draws distinct combinations from each input's full set of steps (many more values than the grid's 12), which over a large space finds a region as good as the grid's best in a fraction of the runs. Refine spends 60 percent of the budget on that random search, then walks from its best one input at a time to whichever neighbouring value does better, until nothing does or the budget is spent. Everything random is seeded: the same seed tries the same combinations in the same order, so a search can be run again and compared. The table says which search ran and how many combinations it tried, and every one of them is counted in your trial ledger.
Walk-forward cuts the period into windows: 5 unless you say (2 to 12), each an in-sample stretch followed by an out-of-sample one, the in-sample 3 times as long (1 to 8), rolling forward, or anchored at the first bar and growing. In each window the search's best by your objective on the in-sample stretch is traded, unchanged, on the next stretch it has not seen. Those out-of-sample stretches, joined, are the result: net, trades, win rate, profit factor, Sharpe and drawdown, and a small equity line. It says how often the same inputs won, per input how often the windows agreed, and an efficiency: out-of-sample net per bar over in-sample net per bar, where near 1 kept up and near 0 did not.
Each combination runs once over the whole period and every window reads its stretch out of that run, so a walk-forward costs what a sweep costs. A window's stretch therefore starts with its indicators warm and its balance where the earlier bars left it, and the joined curve adds each window's changes in equity rather than its levels. Refine is not offered here, because it would have to walk again for every window: asked for, it runs as the random search it starts from.
Markets runs the file with the panel's inputs (or a sweep row's, from the row's Markets) on other markets over the same period: the majors and gold are offered, and any symbol can be typed, 12 at most. Each market is its own run with its own bars, contract size and conversion, and the panel says on how many it made money and the median net. A portfolio (config.markets) already trades several markets and is refused there.
A sweep is the fastest way there is to fool yourself, so it is built not to. Each run is cut at 70 percent of the period. The table ranks on the first part, which is what the inputs were chosen by, and shows the last part beside it, which nothing was chosen by. It says how many variants it tried and what the chance is that at least one looks good by luck alone, and it warns when the best variant lost money on the held-out part. Every sweep is counted in your trial ledger with the other tests of that market, so a hypothesis tested after it is told how many looks came before. A robust region, where neighbouring values all hold up after the split, is worth more than the single best row.
Use runs a row's values in the panel. Write puts them into the file, changing only the number in each input() call, and only when the call is written plainly: input("fast", base * 2) cannot be rewritten and is left alone, with a message saying so.
Input kinds
Past numbers, switches and text, input has kinds of its own. Each is shown its own way in the panel and none is ever swept. Like every input, the FILE's value is what goes live.
| Call | Returns, and what it accepts |
|---|---|
| input.source("src", "close") | That price field as a SERIES. One of "open", "high", "low", "close", "hl2", "hlc3", "ohlc4" or "hlcc4", written in lower case. |
| input.timeframe("htf", "4H", { options: ["1H", "4H", "1D"] }) | The chosen timeframe, for tf(), or for intrabar() and volumeDelta(). options are REQUIRED and written out, at most 50, each a timeframe tf() reads, or 1M, 5M or "auto" for the lower bars intrabar() reads; the value has to be one of them. Case does not matter: "4h" is read as "4H", and the answer is always upper case. |
| input.symbol("other", "XAUUSD", { options: ["XAUUSD", "XAGUSD"] }) | The chosen market, for sym(). options are REQUIRED and written out, at most 50, each 3 to 20 letters and digits; the value has to be one of them. Read in upper case, so "xauusd" is "XAUUSD". |
| input.session("win", "08:00-12:00", { tz: "Europe/London" }) | A window written "HH:MM-HH:MM" (24:00 allowed as its end, the two ends not the same), answered as { from, to, tz }, which inSession() takes as it is. tz is an IANA zone, UTC unless you say. |
| input.color("up", "#34d399") | A colour written as hex: #rgb, #rrggbb or #rrggbbaa. |
| input.time("from", "2024-01-01") | Epoch milliseconds. "YYYY-MM-DD" or "YYYY-MM-DDTHH:MM", read in UTC, or a number of epoch milliseconds. |
input.timeframe and input.symbol take their options written out, and only that way, because every option is loaded before the run: const htf = input.timeframe(...) then tf(htf, (s) => s.close) runs in the editor and goes live. A list built while the file runs, or no options at all, is refused. A value typed into the panel that is not one of the options is ignored.
config = { name: "Inputs of every kind", symbol: "EURUSD", interval: "1H" };
const src = input.source("src", "hlc3");
const htf = input.timeframe("htf", "4h", { options: ["4H", "1D"] }); // read as "4H"
const win = input.session("win", "07:00-16:00", { tz: "Europe/London" });
const up = input.color("up", "#34d399");
const from = input.time("from", "2024-01-01");
const trend = tf(htf, (s) => s.ema(s.close, 50));
const inWindow = inSession(win);
const fast = ema(src, 20);
const vol = atr(14);
onBar = (ctx) => {
if (!vol.ready || trend.now === null || fast.now === null) return;
plot("fast", fast.now, { color: fast.now > trend.now ? up : "#fb7185" });
if (ctx.bar.time < from || !inWindow.now) return;
if (ctx.flat && fast.now > trend.now) ctx.buy({ lots: 0.5, stop: 2 * vol.now! });
else if (ctx.long && fast.now < trend.now) ctx.close();
};Portfolios: several positions, several markets
Name other markets in config and the strategy becomes a portfolio: one account, several positions at once, on any of the markets named. A strategy that does not name markets runs exactly as before.
config = { name: "EUR/GBP pair", symbol: "EURUSD", interval: "1D", markets: ["GBPUSD"], balance: 20000, leverage: 30, spread: 0.8 };
const gbp = sym("GBPUSD", (s) => s.close);
const z = zscore(div(close, gbp), 60);
plot(z, { name: "ratio z", pane: "below" });
onBar = (ctx) => {
if (!z.ready) return;
const inTrade = ctx.positions.length > 0;
if (!inTrade && z.now > 2) { ctx.sell({ lots: 0.5 }); ctx.buy({ symbol: "GBPUSD", lots: 0.5 }); }
else if (!inTrade && z.now < -2) { ctx.buy({ lots: 0.5 }); ctx.sell({ symbol: "GBPUSD", lots: 0.5 }); }
else if (inTrade && Math.abs(z.now) < 0.5) ctx.closeAll();
};| In a portfolio | Meaning |
|---|---|
| ctx.buy({ symbol?, ... }) | Opens a NEW position, on symbol or on this strategy's own market. Called on three bars, it holds three. Every order field works, entry included. |
| ctx.orders | Entry orders still waiting to fill, with ids. ctx.close(id) withdraws one; one that waited its expires bars lapses. |
| ctx.positions | Every open position, each with its id and symbol as well as the usual fields. |
| ctx.close(what?) | Nothing: every position on this market. A position's id: that one. A symbol: every position on that market. |
| ctx.closeAll() | Every position on every market. |
| ctx.modify(levels, id?), ctx.reduce(percent, id?) | As before, on the position with that id, or this market's most recent one. |
| config.maxPositions | The most positions held at once. 10 unless set. |
| config.maxEntries | The most entries on one market in one direction, orders waiting included. No cap unless set. See Order types and tags. |
| markets: [] | A portfolio on this market alone: how a strategy adds to a position or pairs entry orders with oco. |
- The clock is this strategy's own market: onBar is called at the close of each of its bars, and its series are the ones in scope. Read the others with sym(), which a portfolio loads with the same bars it trades.
- A decision is acted on at the next open of the market it names. A market with no bar at that time keeps the order until it has one.
- Every fill, stop, target, partial and break-even is decided by the same broker as a one-market run, market by market. The account is one: a single balance, equity marked across every market, and the margin stop-out judged across all of them at once.
- spread, slippage and commission are applied to every market, spread and slippage in each market's own pips.
- The chart shows this market's trades; the panel's numbers count every market, and list each one's.
- A portfolio goes live like any strategy, with its markets written out as strings. At each closed bar of its own market it runs in the server sandbox over every market's closed bars, and what that bar decided becomes forward orders: opens with their stops, targets, partial targets and break-even, closes, and moved stops, including those a trail moved. Each position is tracked by the bar it was decided on, so a close twenty bars later reaches the trade its open placed. A ctx.reduce closes that share of the forward trade.
Going live
Going live runs the same source on the server, inside the sandbox, once per closed bar, from a cron. Two rules have to be satisfied first, and both fail loudly on purpose: a live strategy that quietly reads a condition as false holds its position and says nothing, while you believe something is watching the market for you.
First, the market and the currency have to be written out. The live runner cannot fetch from inside the sandbox, so it loads the other market and the calendar by reading your source before the run. What it cannot read there is a condition that would be missing at the bar.
tf("4H", s => s.close) // goes live
sym("XAUUSD", s => s.close) // goes live
surprise("USD") // goes live
surprise() // goes live: the pair's own currency
rateDiff("EUR", "USD") // goes live
yieldSpread("EUR", "USD", 2) // goes live
macro("US10Y") // goes live
macro("US_CPI_YOY") // goes live
tf(pickTimeframe(), s => s.close) // refused
sym(pair, s => s.close) // refused
surprise(pick()) // refusedThe check is a scan of the source, not a parse, and it over-reports: a mention inside a comment counts. That is the safe direction, so a commented-out tf(chosen, ...) blocks Go live until you delete it.
Second, the size. Lots go through as they are. A size written as risk, riskMoney or margin is not sized from the backtest's balance, which is the config's and not your account's: it is sized when the order is sent, from the forward account's equity at that moment, with the same maths the backtest uses. risk: 0.5 on an account at 12,000 risks 60 at the stop. In signals mode there is no account, so the email says what the order asked for, for example risking 0.5% of the account at the stop.
Everything else is unchanged live: the same order validation, the same per-bar decision, the same engine, and the stop and target your code chose per entry are carried through, as levels when you wrote levels. takes and breakeven go onto the forward trade as its take-profit ladder and break-even trigger. A ctx.modify while holding moves the live trade's stop or target on the same bar, and in signals mode the email says so.
The live position follows its backtest in two more ways. When the run's trail or break-even moves the stop, the live stop is moved on the same bar. An order with entry is placed on the forward account as an order waiting at the same level, and cancelled at the close of the last bar the run's own order may fill on, so it can never fill live on a bar the backtest's order was not on. A ctx.reduce closes that share of the forward trade at market, recorded as a partial exit of the same position.
- An entry with a time limit (a number of bars, 'day' or 'ioc') is cancelled at the close of the last bar it may fill on, so the forward order rests exactly as long as the run's did.
- A tag goes onto the forward trade as its confluences.
- A stop-limit entry is refused at Go live for now: a forward account has no stop-limit order. See Stop-limit entries.
- A saved indicator read with indicator() runs as the version kept when the strategy went live: see Saved indicators. intrabar() is checked against the live window of 1,500 bars: see Currency rates and lower timeframes.
- config.risk is checked on the run's own account and, in forward mode, maxDailyLoss and maxConsecutiveLosses on the forward account as well; the forward account charges its own commission, not config.commission. See Risk limits and Commission.
- An add (markets: [] with maxEntries, or any portfolio buy) is its own forward trade, tracked by its own key. An add the run refuses because of maxEntries is never sent, and neither is an open past config.maxPositions: the cap is judged when the strategy decides, in the order the next open applies it.
- oco goes live as a group on the forward account. Each leg is placed as its own pending order, filed under the live strategy, the market and the group name, so two strategies never cancel each other's orders. The forward account's one-minute broker fills one leg per group and cancels the others the moment it does, so both legs can never fill.
- Which leg fills is decided minute by minute, not on your strategy's bar. When one minute reaches both legs, the leg nearer that minute's open fills and a tie goes to the order placed first: the backtest's rule, applied to a minute. On your own timeframe the backtest makes that guess once for the whole bar, so on a wide bar that reached both levels the forward account can fill the other leg. The live run does not use config.magnify (it replays the plain bars), so when that happens the next closed bar sees its own leg filled instead, and the live position, which the run does not hold, is closed at market. A backtest with config.magnify, whose finer bars see which level came first, is the better preview of what live will fill.
Library modules
Code you use in more than one script can be saved once as a library module and imported by any strategy or indicator. Open the modules list from the package button beside Code: name a new module, or save the open file as one. A module opens in the same editor; Check compiles it on its own and lists what it exports, and Save stores it.
A module is a file that exports what other files should use. It runs in the same scope as the file that imports it, so it can call ema, atr, series, tf and everything else here, and a helper can return a series.
// lib/my-utils: saved once, imported by any strategy or indicator.
export const LENGTH = 20;
/** How far a series is from its mean over n bars, in standard deviations. */
export function zscoreOf(s: Series, n: number = LENGTH): Series {
return zscore(s, n);
}
/** 1 while the market is quieter than usual: its volatility below its own average. */
export function squeeze(n: number = LENGTH): Series {
const vol = stdev(close, n);
return lt(vol, sma(vol, n * 2));
}A strategy imports it by name, with lib/ in front:
import { zscoreOf, squeeze as isQuiet } from "lib/my-utils";
config = { name: "Quiet reversion", symbol: "EURUSD", interval: "1H", balance: 10000, leverage: 30, spread: 0.8 };
const z = zscoreOf(close, 30);
const quiet = isQuiet();
onBar = (ctx) => {
if (!z.ready || !quiet.ready) return;
if (ctx.flat && quiet.now && z.now < -1.5) ctx.buy({ lots: 0.5, stop: 0.004, target: 0.006 });
if (ctx.long && z.now > 0) ctx.close();
};| Written | Means |
|---|---|
| import { a, b } from "lib/my-utils" | Named imports. Only the names a module exports. |
| import { a as other } from "lib/my-utils" | The same, under another name in this file. |
| import * as utils from "lib/my-utils" | Everything the module exports, as one object: utils.zscoreOf(...). |
| import { type Band } from "lib/my-utils" | A type only, for annotations. Nothing of it runs. |
| export function f() {} / export const x = ... | In a module: what it shares. |
| export { a, b as c } / export * from "lib/other" | In a module: a list, or everything another module exports. |
- Only your own modules can be imported, spelled lib/ and a name of lowercase letters and digits joined by hyphens. There are no default imports and no default exports.
- A module never sets config or onBar: those belong to the file that imports it. Its own top-level names stay private unless it exports them.
- A name you import cannot also be declared in the file: rename one with as. An imported name may shadow a runtime name (an import called ema is yours, not the runtime's), as a const of your own would.
- Modules can import other modules, five levels deep at most, twenty per script in all, and never in a circle.
- Imports are joined into the file before it runs, so the runtime, the worker, the live sandbox and the scan that preloads data all see one file. A tf("4H") or surprise("USD") inside a module is loaded like one in the file, and held to the same going-live rule.
- A live strategy, a script alert and an indicator added to the chart keep the module code they were started with. Editing a module changes the next Run, not what is already running: start it again, or press Update on chart, to take the new version.
The type checker
The editor runs the real TypeScript compiler in a worker on a pause in typing, over three files: a flattened standard library with no DOM in it, the runtime declarations, and your source verbatim. document and fetch are errors here because they genuinely are not there.
It is advisory and never disables Run. Types are stripped before a strategy runs, so a marker that disagreed with the thing that actually executes the file would be worse than no marker. What does disable Run is compilation failing: a syntax error, or a file that never sets onBar. The checker is also not strict, and it stays quiet about assigning to config and onBar, and about a name you computed and used in only one branch.
One known gap, where the generated types are narrower than the runtime: tf and sym are not on the Market type a builder is handed, so s.tf(...) is marked. It is also not usable in practice, because a nested call is not found by the scan that preloads markets.
Worked examples
Each of these compiles and books trades against the real runtime.
A simple trend filter.
config = {
name: "Above the mean",
symbol: "EURUSD",
interval: "1H",
balance: 10000,
leverage: 30,
spread: 0.8,
commission: 7,
};
const trend = sma(close, 100);
onBar = (ctx) => {
if (!trend.ready) return;
if (ctx.flat && close.now > trend.now) ctx.buy({ lots: 0.5, stop: 0.0040, target: 0.0080 });
if (ctx.long && close.now < trend.now) ctx.close();
};Risk sized by volatility, managed after entry. The point of writing a strategy as code: the risk is an expression, not a setting. This one cannot go live as written, because it sizes with risk.
config = {
name: "Volatility sized cross",
symbol: "EURUSD",
interval: "1H",
balance: 25000,
leverage: 30,
spread: 0.8,
commission: 7,
minHold: 2,
maxDrawdown: 0.3,
};
const fast = ema(close, 20);
const slow = ema(close, 50);
const vol = atr(14);
const calm = sma(vol, 50);
onBar = (ctx) => {
if (!vol.ready || !calm.ready || !slow.ready) return;
if (ctx.long && crossBelow(fast, slow).now) {
ctx.close();
return;
}
if (ctx.flat && crossAbove(fast, slow).now) {
const quiet = vol.now < calm.now;
ctx.buy({
risk: quiet ? 0.5 : 0.25,
stop: (quiet ? 2 : 3) * vol.now,
breakeven: 1,
takes: [{ at: 1.5, percent: 40 }],
trail: { distance: 2 * vol.now, fromR: 1 },
hold: 120,
});
}
};The calendar. released() is the gate and surprise() is the size of the miss. It can go live as it is.
config = { name: "Dollar print fade", symbol: "EURUSD", interval: "1H", balance: 25000, leverage: 30, spread: 0.8 };
const fresh = released("USD");
const shock = surprise("USD");
const vol = atr(14);
onBar = (ctx) => {
if (!vol.ready || !shock.ready) return;
// Out after a day, whatever happened.
if (ctx.long && ctx.position !== null && ctx.position.bars >= 24) {
ctx.close();
return;
}
if (!ctx.flat || !fresh.now) return;
// A weak US print is a weaker dollar, and a weaker dollar is EURUSD up.
if (shock.now < -1) ctx.buy({ lots: 0.2, stop: 2 * vol.now, target: 3 * vol.now });
};Another market and a higher timeframe. Both are written out as literals, which is what loads them.
config = { name: "Gold leads the euro", symbol: "EURUSD", interval: "1H", balance: 25000, leverage: 30 };
const gold = sym("XAUUSD", (s) => s.ema(s.close, 50));
const goldNow = sym("XAUUSD", (s) => s.close);
const higher = tf("4H", (s) => s.ema(s.close, 50));
const vol = atr(14);
onBar = (ctx) => {
if (!vol.ready || !gold.ready || !goldNow.ready || !higher.ready) return;
const goldUp = goldNow.now > gold.now;
const trendUp = close.now > higher.now;
if (ctx.flat && goldUp && trendUp) ctx.buy({ lots: 0.3, stop: 2 * vol.now });
if (ctx.long && !trendUp) ctx.close();
};Your own state and your own functions. This is what a rule language cannot say.
config = { symbol: "EURUSD", interval: "1H", balance: 25000, leverage: 30 };
const vol = atr(14);
function upCount(n) {
let count = 0;
for (let k = 0; k < n; k++) {
const a = close.ago(k);
const b = close.ago(k + 1);
if (a !== null && b !== null && a > b) count++;
}
return count;
}
let wins = 0;
onBar = (ctx) => {
if (!vol.ready) return;
if (ctx.flat && upCount(10) >= 8) ctx.buy({ risk: wins > 2 ? 0.2 : 0.5, stop: 2 * vol.now, hold: 12 });
if (ctx.long && ctx.position !== null && ctx.position.r !== null && ctx.position.r > 2) {
wins++;
ctx.close();
}
};Coming from Pine
Import Pine in the Builder converts a TradingView script into a file for this editor, in the page, line by line and the same way every time. What it cannot convert is kept as a // TODO(pine): comment with the reason, and listed, and Ask the assistant to finish it hands exactly that list to the assistant. The assistant does the same when you paste Pine into the chat: it runs the converter first and finishes the rest.
The difference to keep in mind: Pine runs the whole script on every bar, and here an indicator is declared ONCE at the top as a whole series and read with .now. Anything with state, var and :=, lives in onBar.
| Pine | Here | Note |
|---|---|---|
| ta.sma, ta.ema, ta.rma, ta.wma, ta.hma, ta.rsi, ta.stdev | sma(src, n), ema, rma, wma, hma, rsi, stdev | ema is seeded with the first value and rma with a simple average, as Pine seeds them. |
| ta.atr(n), ta.tr | rma over Pine's true range, true_range() | ta.atr is written as rma(n) over a true range whose first bar is its high minus its low, as Pine's ta.tr(true) is, so it matches Pine from the first reading. The runtime's own atr(n) starts a bar later. ta.tr has no first-bar reading in either. |
| ta.highest(n), ta.lowest(n) | highest(high, n), lowest(low, n) | |
| ta.crossover(a, b), ta.crossunder(a, b) | crossAbove(a, b), crossBelow(a, b) | 1 on the crossing bar: read it with .now === 1. |
| ta.macd(src, fast, slow, signal) | macd, macd_signal, macd_hist | [line, signal, hist] = ... becomes three series. |
| ta.bb(src, n, mult) | sma, bb_upper, bb_lower | The population deviation, as Pine's. |
| ta.supertrend(factor, n) | supertrend(n, factor), supertrend_dir(n, factor) | The arguments swap, and Pine's direction is -1 in an uptrend where this one is 1. The converter flips it. |
| ta.pivothigh(left, right) | pivot_high(high, left, right) | On the bar that confirms it, as in Pine. |
| ta.barssince(c), ta.valuewhen(c, x, 0) | bars_since(c), a let set in onBar | |
| ta.valuewhen(c, x, n) with a fixed n above 0 | valuewhen(c, x, n) | A series over the whole run. An occurrence worked out while the script runs is not converted. |
| [plus, minus, adx] = ta.dmi(n, smoothing) | dmi_plus(n), dmi_minus(n), adx(n) | adx here takes one length for the DI and the smoothing, so the adx part converts only when the two are the same. |
| ta.vwap, ta.vwap(hlc3, anchor) | vwap(), vwap(anchor) | Converted on hlc3 only, without the bands. It restarts at each UTC midnight or where the anchor is true; TradingView's restarts at the session open. |
| ta.obv | obv() | On FX the volume is the feed's tick count. |
| ta.mfi(hlc3, n) | mfi(n) | Converted on hlc3 only, the typical price mfi reads. |
| ta.roc(src, n), ta.mom(src, n) | 100 * (src - src.ago(n)) / src.ago(n), src minus src.ago(n) | Written out as arithmetic. The runtime also has roc(src, n): see More indicators. |
| ta.wpr(n) | 100 * (close - highest(high, n)) / (highest(high, n) - lowest(low, n)) | Written out as arithmetic. The runtime also has wpr(n): see More indicators. |
| ta.sar(start, inc, max) | sar(start, inc, max) | |
| ta.linreg(src, n, offset) | linreg(src, n, offset) | |
| ta.correlation(a, b, n) | correlation(a, b, n) | |
| ta.highestbars(src, n), ta.lowestbars(src, n) | highestbars(src, n), lowestbars(src, n) | With only a length they read high and low, as in Pine. |
| ta.alma(src, n, offset, sigma) | alma(src, n, offset, sigma) | Not converted with floor set to true. |
| ta.tsi(src, short, long) | tsi(src, short, long) | Its ema seeds with the first value, so the earliest readings differ from TradingView's until that has decayed. |
| ta.percentile_linear_interpolation(src, n, p), ta.percentile_nearest_rank(src, n, p) | the percentile of the last n readings, written into the file | Pine's two methods: between the two nearest ranks, or at the nearest rank. na until there are n readings, or while one is na. |
| Aroon, written with ta.highestbars and ta.lowestbars | aroon(n), aroon_up(n), aroon_down(n) | Pine has no built-in Aroon. The converter keeps the highestbars and lowestbars arithmetic; write aroon(n) by hand for the runtime's own. |
| syminfo.mintick, syminfo.pointvalue | symbol.tickSize, symbol.pointValue | pointValue is account money per 1.0 of price per lot, at the bar being decided. See Market info and money. |
| syminfo.ticker, syminfo.tickerid, syminfo.currency, syminfo.basecurrency, syminfo.type, syminfo.description | symbol.name, symbol.quote, symbol.base, symbol.type, symbol.description | The name has no exchange prefix. The type's words are this platform's: forex, metal, energy, index, crypto and the rest. syminfo.type == "fund" is read as etf; a word no market here is (futures, cfd and the like) compares as never equal, and a note says so. syminfo also works as another name for symbol. See Market info and money. |
| barstate.isfirst, barstate.islast | ctx.index === 0, barState.isLast | isLast is the last bar of the data in a backtest and every bar a live run decides: use it for drawings and alerts, not for trades. See Bar state and time. |
| barstate.ishistory, barstate.isrealtime | barState.isHistory, barState.isLive | isLive is true only on the bar a live run or an alert decides for real, never in a backtest. |
| barstate.isconfirmed, barstate.isnew | true | Every bar a script sees has closed. |
| barstate.islastconfirmedhistory | barState.isLast && barState.isHistory | The last bar of a backtest's data, never a bar a live run decides for real, as TradingView has it once realtime bars come in. |
| request.security_lower_tf(syminfo.tickerid, "15", e) | intrabar("15M", s => ...) | Converted on this chart's own market: each bar's lower bars as an array, which the array functions read. [a, b] = request.security_lower_tf(..., [x, y]) is one intrabar() per value. The timeframe is written out, or worked out when converting: a name declared as one, an input's default, or a choice made by conditions on the inputs' defaults. A choice made by the chart's own timeframe (timeframe.isintraday and the like, TradingView's automatic choice) becomes intrabar("auto"). The lower bars are loaded before the run, so an input that picks the timeframe is read at its default and a note says so. One run reads at most 50,000 lower bars: an indicator past that reads them on its newest chart bars only and the older ones read [] with a warning, as TradingView does past its intrabar limit; a strategy is refused. Another market's lower bars are a TODO. See Currency rates and lower timeframes. |
| import TradingView/ta/N, then ta.requestVolumeDelta(tf, period) | volumeDelta(tf, { anchor }) | [open, max, min, last] = ... becomes volumeDelta's open, high, low and close: the cumulative volume delta from the lower bars inside each bar, starting again at 0 each anchor period (in UTC). The lower timeframe is worked out as request.security_lower_tf's is; the period is a text or a text input, "60" read as 1H, "D" as 1D, "W" as 1W, "M" as a month. Tick data ("1T") and seconds are not read here. The same 50,000 lower bar budget as intrabar(): an indicator past it reads na on its oldest chart bars with a warning, a strategy is refused. See Currency rates and lower timeframes. |
| ta.requestUpAndDownVolume(tf) | volumeDelta(tf).up, .down, .delta | [up, down, delta] = ... from the same lower bars: the bar's up volume, down volume (negative) and their sum. |
| x[1] | x.ago(1), or ref(x, 1) as a series | The history of text, a colour or a drawing (state[1], line.new(...)[1]) is kept bar by bar in a PineHistory written into the file, since a series holds numbers only. Inside a function a value's history is its value at that call site's earlier calls, as Pine keeps it; ta.change(x) of such a value is x minus that. |
| var x = 0, then x := x + 1 | let x = 0 above onBar, x = x + 1 inside it | Its history needs a series("x") written with .set(x) after each change. |
| na, nz(x), na(x) | null, or NaN inside arithmetic | A series written with NaN reads back as null, which is what na is here. |
| input.int(14, "Length", minval=1) | input("length", 14, { min: 1, label: "Length" }) | |
| input.string("A", "Mode", options=["A", "B"]) | input("mode", "A", { options: ["A", "B"], label: "Mode" }) | A list in the panel; without options, a text field. The default and the options may be Pine's words (line.style_dashed, size.tiny) or text constants of the script's own. More than 50 options, or input.enum over an enum of more than 50 members, is a text field, and a note says so. |
| input.source(close, "Source") | input.source("src", "close") | A price field picked in the panel, read as a series. The converter writes it as a plain series for now: change that line to input.source to get the picker. See Input kinds. |
| input.timeframe("240", "HTF", options=[...]) | input.timeframe("htf", "4H", { options: ["1H", "4H", "1D"] }) | Every option is loaded before the run, so the options are written out. The converter writes input(name, value, { options }) when the Pine options list is usable (its default is one of them, none repeated), else input(name, value), and reads tf() with the value it had at conversion; switch to input.timeframe by hand, and read tf() with what it returns, to pick it in the panel. Its options are case-insensitive. See Input kinds. |
| input.symbol("XAUUSD", "Other", options=[...]) | input.symbol("other", "XAUUSD", { options: ["XAUUSD", "XAGUSD"] }) | As input.timeframe: options written out. The converter writes input(name, value, { options }) when the Pine options list is usable, else input(name, value), and reads sym() with the value it had at conversion; switch to input.symbol by hand to pick it in the panel. See Input kinds. |
| input.session("0800-1200", "Window") | input.session("win", "08:00-12:00", { tz: "Europe/London" }) | Written "HH:MM-HH:MM" here, where Pine writes "0800-1200". Returns { from, to, tz }, which inSession() takes. The converter still writes a text input; write input.session by hand. See Input kinds. |
| input.color(color.green, "Up") | input.color("up", "#34d399") | A hex colour. The converter writes the colour as a constant. |
| input.time(timestamp("2024-01-01"), "Anchor") | input.time("anchor", "2024-01-01") | Epoch milliseconds in UTC. The converter writes a number input holding those milliseconds; switch to input.time by hand for a date in the panel. |
| request.security(sym, "240", e) | tf("4H", s => ...), sym("XAUUSD", s => ...) | A higher bar is shown from the bar it closes on, never before. A TradingView ticker this platform has under another name is read as that market, with a note: an index ETF or future as its index (SPY, ES1! and SPX as US500, QQQ as US100, DIA as US30), gold and oil by their spot price, BTCUSDT as BTCUSD. Inside a block or a function it is made at the top when it reads nothing of the function's own, and from each call's arguments when it reads the function's parameters. time and time_close there are that bar's open and close times (s.bar_time() and s.timeClose). A market chosen by the inputs (use_chart ? syminfo.tickerid : custom_symbol) is read at their defaults, with a note: the market is loaded before the run. |
| request.security(sym, "240", f()) where f keeps a var | tf("4H", s => ...) running f's factory once per bar of that timeframe | Converted when f only computes (no objects, drawings, orders or the chart's bar state): its var lives in the factory and is kept from one bar of that timeframe to the next, bar_index there counts that timeframe's bars, and a value's history and an indicator of its own values are kept bar by bar there. Each call keeps its own. request.security_lower_tf(syminfo.tickerid, tf, f()) runs it over the lower bars the same way, a tuple as one intrabar() per value. With lookahead_on it is a TODO: the forming bar is worked out again at every chart bar inside it, which would move the var each time. A request.security inside f with ignore_invalid_symbol = true is na, as Pine answers for a market it cannot read, and a TODO of its own. |
| request.security(syminfo.tickerid, "M", [time, high[1], low]) | a PinePeriods built from this chart's own bars, written into the file | Converted for this chart's own open, high, low, close, volume and time at a fixed offset: each month ("3M" and "12M" too) is made from the chart bars inside it, counted in UTC, and read from the last one closed, or with lookahead_on the one still forming. tf() reads 15M to 1W, so a month of another market, of an indicator or of a function is a TODO, read as na so what reads it still converts. The chart's first month starts with its first bar. |
| request.security(ticker.heikinashi(syminfo.tickerid), timeframe.period, e) | e over heikinAshi()'s open, high, low and close | Converted at the chart's own timeframe: this chart's Heikin Ashi candles. Another market's or another timeframe's, and an indicator that reads the bar's own high, low and close (ta.atr and the like), are a TODO. |
| request.security(sym, "240", e, lookahead=barmerge.lookahead_on) | tf("4H", s => ..., { forming: true }) | The 4H bar still forming, as TradingView shows it live. With e[1] it stays the closed-bar read. |
| plot, plotshape, plotchar, plotarrow, hline, fill, bgcolor, barcolor | plot, mark, hline, fill, bgcolor, barcolor | A plot whose colour changes bar by bar is drawn in onBar: see Colouring by value. A plot shown nowhere (display.none) that a fill reads is kept as a hidden plot; location.top and location.bottom are placed above and below the bar. v4's bare style names (style=histogram, cross, area) are read as plot.style_histogram and the rest. |
| fill(p1, p2, color = c) with a colour that changes bar by bar | fill("a", "b", { color }) inside onBar | Converted: each bar shaded in its own colour, na leaving it unfilled, the colour's alpha kept. A colour known before the first bar stays one fill at the top. |
| fill(p1, p2, top_value, bottom_value, top_color, bottom_color) | fill("a", "b", { gradient: { top, bottom, topColor, bottomColor } }) | Converted as the gradient Pine draws, at the top of the file when its ends and colours are known there, otherwise bar by bar in onBar. fill(p1, p2, color, title), a colour and a title, is a plain fill. |
| fill(h1, h2) between two hlines, fill(p, hline(4)) | fill("edge", level), the first level also a hidden plot | This runtime fills from a plot, so between two levels one of them is also drawn as a plot nobody sees. An hline written inside fill() is drawn where it stands, as Pine draws it. |
| offset = n on plotshape(...) and plotchar(...) | mark(text, { offset: n }) | Converted, an offset worked out while running included: the mark is drawn n bars away and still belongs to the bar that made it. |
| strategy.entry, strategy.exit, strategy.close | ctx.buy or ctx.sell, stopPrice and targetPrice or ctx.modify, ctx.close | One decision per bar. |
| alertcondition, alert | alert(message, { key, freq, data }) | Raised on the chart where it holds; sent to your email and alert channels with Alert me on. See Alerts. |
| arrays, maps, matrices, type and method, enum, switch, for...in, while | TypeScript arrays and Map, PineMatrix, interfaces and object literals, functions, if and else, for...of | Converted, switch and if as values too, a function's included (its value is the branch's that runs, one value or a tuple), and input.enum as a list of the enum's titles. A function that keeps objects, reads bar_index or reads its parameter's history runs inside onBar, the parameter passed as a series. A function that keeps a var, or takes indicators of its own locals, is written out at each call, so each call keeps its own state as in Pine. One that cannot be written out (called inside an expression or from another function) keeps its vars in an object per place it is called from, found by a key each call passes, nested calls included. A series it makes from its own parameters (an indicator, a request.security, a session) is made at each call from that call's arguments and handed to it. A function over history that makes no indicator of its own may be handed values that change bar by bar: it is made and run where it is called. |
| Indicators inside a function of a value it works out: ta.percentrank(x, n), ta.crossover(close, lv.price), ta.cum(x) | pineCalls(history, call, n, how) over a PineHistory kept call by call; a running total as a let, or a field per call site | Converted as Pine keeps them: per call, over what the value was at the function's earlier calls, per call site when it is called from several places, and a length that changes from call to call included. ta.sma, ta.sum, ta.highest, ta.lowest, ta.highestbars, ta.lowestbars, ta.percentrank, ta.stdev, ta.dev, ta.median, ta.wma, the two percentiles, ta.change, ta.crossover, ta.crossunder and ta.barssince are covered, and ta.cum, ta.max, ta.min and ta.valuewhen keep their running state the same way. A recursive one (ema, rma, rsi) of such a value is a TODO. |
| Sessions: time(tf, session, tz) and time_close(tf, session, tz) | inSession({ from, to, tz }), or inSession(pineSession(text, tz)) read when the script starts | Converted. A session or a zone from an input or a function's parameter is read from its text when the script starts (pineSession, written into the file). time_close() is in the session when the bar's close is, so its window is moved back one bar. timeframe.from_seconds(s) is Pine's timeframe text for it ("15", "1D"). |
| Data this platform does not have, and libraries: request.financial, request.economic, request.footprint, import User/Lib/1 as lib | na, each a TODO of its own | This platform has none of that data, and a library's code is not in the file: each call reads na where it is made, listed as a TODO with its reason, and the rest of the script converts around it, its own na handling deciding what it shows. A value of a library's type is na too, and so are its fields and methods. A request.security of a market built while running (TradingView's own _OI or _LQBUY feeds) with ignore_invalid_symbol = true reads na the same way, and one that cannot be converted at all (another market on another timeframe at once) is na from its line on. |
| '''text''' over several lines; a = 1, b = 2 on one line; for i = n > 0 ? n - 1 : 0 to 0 | one string; two statements; the loop as written | Triple-quoted text is joined into one line, the line numbers after it kept. Statements separated by commas are read one by one. |
| Casts: color(na), string(x), line(na) | null, the value, null | The value as that type, na as that type's na. |
| display.all - display.status_line | a number | Pine's display flags are numbers here, added and taken away as in Pine; a plot is hidden when its display is none, data_window, status_line or price_scale written out. |
| ta.cci(src, n) on a source other than hlc3 | (src - sma(src, n)) / (0.015 * mean deviation) | Written out as Pine defines it; on hlc3 it is the runtime's own cci(n). |
| line, label, box, polyline, linefill and table objects | PineLine, PineLabel, PineBox, PinePolyline, PineLinefill and PineTable, written into the file | Converted. Each keeps Pine's model (made, set, read back with get_*, deleted, max_lines_count and the others) and is drawn in its last state on the last bar through drawLine, label, drawBox, drawPolyline and table.grid. A label has one size, a box's text is a label at its middle, a table goes to the nearest corner. See Drawings that move. |
| last_bar_index, time_close, timenow | ctx.lastIndex, timeClose, Date.now() | Converted. timenow is the close of the bar being decided, never the wall clock. See Bar state and time. |
| last_bar_time | the bar's time plus the bars left times the timeframe | Exact on the last bar, where drawings are made; before it, earlier than TradingView's by the gaps (weekends) still to come. |
| str.format_time, math.random | formatTime(ms, pattern, tz), Math.random() | Converted. Math.random() is seeded per script, so a run draws the same numbers every time. |
| timestamp(tz, y, m, d, h, mm), timestamp("2024-01-10 00:00 +0000") | timestamp(y, m, d, h, mm, tz), a number | Converted: the zone goes last here, and a date written out is its milliseconds. year(t), month(t), dayofmonth(t) and the rest of a time are read in UTC. See Bar state and time. |
| timeframe.change(tf), time(tf), timeframe.in_seconds(tf), time_tradingday | the period's opening time, worked out in UTC | Converted for minutes to months, a timeframe from an input included. TradingView aligns a day to the exchange's session (for FX 17:00 New York), so a period here can open a few hours from TradingView's; time_tradingday is the bar's day at 00:00 UTC. |
| session.islastbar | isLastBarOf(session) | Write it by hand. See Bar state and time. |
| ta.highest, ta.lowest, ta.sum, ta.sma over a length that changes bar by bar | a window read back bar by bar, written into the file | Converted inside onBar, na until there are that many bars, inside a function over its parameter's length too. A length chosen from inputs by a switch or an if is a constant and stays a plain indicator. Every other indicator takes a fixed length, and one over a moving length is a TODO. |
| request.currency_rate(from, to) | fx("EUR", "USD") | Converted with both currencies written out. See Currency rates and lower timeframes. |
| plotcandle and plotbar, with open, high, low and close | plotCandles(o, h, l, c) | Converted, values worked out bar by bar too (each a series written in onBar, na leaving the bar undrawn); force_overlay draws them over the price. Pine's colours are not carried over. See More indicators. |
| plot(x, offset = n) | plot(x, { offset: n }) | Converted for an offset known before the first bar. Only the drawing moves. See Drawings that move. |
What stays a TODO(pine): request.security and request.security_lower_tf on a symbol or timeframe worked out while the script runs, or on another market at another timeframe at once; a month of another market, of an indicator or of a function; a function that keeps a var read with lookahead_on; lower bars of another market; tick and second data; an indicator over a length that changes bar by bar (other than the four above, and those inside a function over its own values); a recursive indicator (ema, rma, rsi) of a value a function works out itself; ta.stdev with biased=false, ta.alma with floor, ta.vwap's bands; trailing exits, partial exits and an entry with both a limit and a stop; session.islastbar; and anything that reads one of those. Each is listed with its reason, and the rest of the file still runs. request.financial and the other data this platform does not have, and what a script calls from a library it imports, are TODOs too, but each reads na where it is asked for, so what reads it still converts.
The drawing limits: a script keeps the 50 most recent lines, labels, boxes and polylines of each kind unless its indicator() says otherwise with max_lines_count and the others (up to 500), the oldest deleted first as in Pine, and the chart draws at most 500 of each. A polyline has 100 points at most, a label 80 characters, and a table becomes a grid of 20 rows by 8 columns of 40 characters, in the nearest corner. A label has one size. The objects are kept in the file and drawn in their last state on the last bar (on every bar a live run decides).
An RSI with levels, as the converter writes it:
// Converted from Pine Script v5 by the AnalyticsFX converter.
config = { kind: "indicator", name: "RSI with levels", overlay: false };
const num = (v: number | null | undefined): number => (v === null || v === undefined ? NaN : v);
const length = input("length", 14, { min: 1, label: "Length" });
const upper = input("upper", 70, { label: "Overbought" });
const lower = input("lower", 30, { label: "Oversold" });
const r = rsi(close, length);
hline(upper, { label: "Upper", color: "#787B86", pane: "RSI" });
hline(lower, { label: "Lower", style: "dotted", pane: "RSI" });
onBar = (ctx: Ctx) => {
plot("RSI", num(r.now), { color: num(r.now) > upper ? "#FF5252" : "#2196F3", width: 2, pane: "RSI" });
bgcolor(num(r.now) < lower ? "rgba(76, 175, 80, 0.15)" : null, { pane: "RSI" });
};Common mistakes
Each of these is a real message from the runtime.
Passing a series where a number belongs, which is forgetting .now:
stop is a number, not a series. Read the series at this bar with .now.
Using an indicator before it has a reading:
stop has to be a number above zero, not 0. An indicator with no reading yet gives this: guard the bar with if (!atr(14).ready) return; before you use it.
Two decisions on one bar:
This bar has already decided what to do. One buy, sell or close per bar.
Writing a series you made at the top of the file, where there is no bar yet:
conviction.set() writes the bar being decided, so it belongs inside onBar. At the top of the file there is no bar yet.
Moving a stop to where it would close the trade:
stopPrice 1.12 is on the wrong side of the price (1.105): a long's stop goes below it. To get out, use ctx.close().
Two sizings at once:
A size is one of lots, risk, riskMoney or margin, not several at once.
Risk with no stop:
A size written as risk needs a stop, because the risk is what a stop-out costs.
A type annotation on config, which JavaScript reads as a label:
config is assigned, not declared, so it cannot carry a type here: write config = ... and let the runtime's own declaration give it its type.
A bare currency word instead of a string:
surprise() takes a currency in quotes, e.g. surprise("USD").
An expression passed to tf or sym instead of a builder:
tf() takes a FUNCTION as its second argument, which is handed the other market.
A builder that reads instead of building:
sym("XAUUSD", ...) has to RETURN a series, e.g. s => s.close or s => s.ema(s.close, 50).
A series set by hand inside a builder, which has no onBar:
series() inside a tf() or sym() builder takes a function, s.series(() => s.high.now - s.low.now): there is no onBar there to set() one.
Forgetting what the strategy trades:
This strategy does not say what it trades. Set config = { symbol: "EURUSD", interval: "1H" } at the top.
Too much work per bar. The clock is checked between bars:
This strategy is still running after 5000 ms, on bar 812 of 3000. It is doing too much work per bar: move anything that does not change out of onBar, and compute indicators once at the top rather than inside a loop.
A loop that never ends. Nothing can interrupt one from the thread it runs on, which is why the browser runs a strategy in a Worker it can kill and the server runs it inside an interpreter with an interrupt handler:
This strategy was still running after 5 seconds and was stopped. Look for a loop that never ends.
Reading ahead:
close.ago(-1) asks for a bar that has not happened yet.
