Coming from another technology
From MQL4 and MQL5
A MetaTrader indicator is a program with buffers, a lifecycle and an index order of its own. A VP Script is a function over arrays. Most of the port is deletion. The one thing to be careful about is is which end of the array you are standing at.
There is no OnInit, OnCalculate or OnDeinit
An MQL indicator declares buffers, binds them in OnInit, fills them in OnCalculate from a prev_calculated watermark, and tidies up in OnDeinit. None of that exists here: the whole script is the calculation, it runs over the whole series, and what you return is what is drawn.
#property indicator_chart_window
#property indicator_buffers 1
#property indicator_plots 1
double MaBuffer[];
int OnInit() {
SetIndexBuffer(0, MaBuffer, INDICATOR_DATA);
return INIT_SUCCEEDED;
}
int OnCalculate(const int rates_total, const int prev_calculated,
const datetime &time[], const double &open[],
const double &high[], const double &low[],
const double &close[], const long &tick_volume[],
const long &volume[], const int &spread[]) {
int start = (prev_calculated == 0) ? 20 : prev_calculated - 1;
for (int i = start; i < rates_total; i++) {
double sum = 0;
for (int k = 0; k < 20; k++) sum += close[i - k];
MaBuffer[i] = sum / 20;
}
return rates_total;
}indicator('MA', { overlay: true })plot(ta.sma(close, 20))That is the whole port. The buffer, the binding, the incremental watermark and the manual loop all go, because the engine already owns recomputation. It decides when to run your script and hands it every bar each time.
The indexing runs the other way
This is the one that will produce a plausible, wrong chart. In MQL4, and in MQL5 whenever ArraySetAsSeries is on, index 0 is the CURRENT bar and the index grows backwards into history. In VP Script index 0 is the oldest bar and the last element is the newest, the ordinary array order, and the same order pandas and TA-Lib use.
| Meaning | MQL (as-series) | VP Script |
|---|---|---|
| Current bar | close[0] | close[close.length - 1] |
| Previous bar | close[1] | close[close.length - 2] |
| N bars ago | close[n] | ta.shift(close, n) |
| Oldest bar | close[Bars - 1] | close[0] |
ta.* call, this is the line to check twice.In practice you rarely index at all. Anything you would have written as a backwards loop is a whole-array transform here, and those have no direction to get wrong.
double hh = 0;
for (int k = 0; k < 20; k++) hh = MathMax(hh, high[i + k]);const hh = ta.highest(high, 20)Buffers become returned series
An MQL indicator communicates through buffers, and how many you declare is fixed at compile time. Here a series is just a value: compute as many as you like, plot the ones worth seeing, and the rest are intermediates nobody has to allocate a slot for.
indicator('Bands', { overlay: true }) const [upper, mid, lower] = ta.bbands(close, 20, 2) plot(upper, { color: '#787b86' })plot(mid, { color: '#2962ff' })plot(lower, { color: '#787b86' })fill(upper, lower, { color: 'rgba(41,98,255,0.06)' })EMPTY_VALUE has no equivalent and needs none. A gap is null, it propagates through every transform, and it renders as a break in the line. The MQL habit of writing 0.0 or EMPTY_VALUE into a buffer during warm-up should become nothing at all: leave the nulls where they are.
What maps to what
| MQL | VP Script | Note |
|---|---|---|
iMA(...) | ta.sma / ta.ema / ta.wma | Pick the method rather than passing a mode. |
iRSI(...) | ta.rsi(close, n) | |
iATR(...) | ta.atr(n) | |
iMACD(...) | ta.macd(close, 12, 26, 9) | |
iCustom(...) | – | There is no cross-script call. Compose in one script. |
CopyBuffer / CopyRates | request.security(...) | Declared, then fetched by the host. |
input int Period = 14; | input.int('Period', 14) | Becomes a real settings dialog. |
PlotIndexSetInteger(...) | plot(series, ) | Style rides with the call. |
ObjectCreate(OBJ_HLINE) | hline(price) | |
Alert() / SendNotification() | alertcondition(cond, ) | You declare it; the host delivers it. |
Print() / Comment() | – | Plot the series instead; see Visuals. |
TimeCurrent() | – | No wall clock. A script is deterministic. |
An indicator here cannot trade
MQL blurs the line: the same language writes indicators and Expert Advisors, and an indicator can reach OrderSend if it wants to. VP Script cannot. There is no trading API, no account access, no network and no filesystem. a script is a pure function from bars to drawings.
That is not a gap to be filled later. It is what makes it safe to run a stranger’s indicator in a reader’s browser, which is the whole point of the community catalogue. If you want a chart to drive something on your own page, such as order lines, brackets or buy/sell markers, that is the host’s job and it has its own API: the embed JavaScript API.
Multi-timeframe, and why it looks stricter
iMA(NULL, PERIOD_H4, ...) on an M15 chart reaches straight into another timeframe, including, depending on how you index it, the H4 bar that is still forming. That is the standard MetaTrader repainting trap: the indicator looks excellent in the tester and disagrees with itself live.
Here the equivalent is request.security, and it is strictly causal by construction. A four-hour bar becomes visible only from the chart bar that opened after it closed, so the forming bar is never readable and there is no setting to make it so. Your ported indicator will look worse in hindsight than the MQL original, and it will behave the same way live, which is the difference you were being shown all along.
The detail, including how a foreign bar’s duration is derived and why a single foreign bar stays null rather than being placed at a guessed close time, is on other symbols and timeframes.
A worked port
A common MetaTrader indicator, end to end:
double UpBuf[], DnBuf[], MaBuf[];
input int MaPeriod = 20;
input double Deviation = 0.5;
int OnCalculate(const int rates_total, const int prev_calculated, ...) {
int start = (prev_calculated == 0) ? MaPeriod : prev_calculated - 1;
for (int i = start; i < rates_total; i++) {
double sum = 0;
for (int k = 0; k < MaPeriod; k++) sum += close[i - k];
double ma = sum / MaPeriod;
MaBuf[i] = ma;
UpBuf[i] = ma * (1 + Deviation / 100);
DnBuf[i] = ma * (1 - Deviation / 100);
}
return rates_total;
}indicator('MA envelope', { overlay: true }) const period = input.int('MA period', 20, { min: 2 })const dev = input.float('Deviation %', 0.5, { min: 0, step: 0.1 }) const ma = ta.sma(close, period)const up = ma.map(v => v === null ? null : v * (1 + dev / 100))const dn = ma.map(v => v === null ? null : v * (1 - dev / 100)) plot(ma, { color: '#2962ff' })plot(up, { color: '#787b86' })plot(dn, { color: '#787b86' }) const broke = close.map((c, i) => up[i] === null ? null : c > up[i])plotShape(broke, { style: 'triangleUp', location: 'aboveBar', color: '#26a69a' })Note what disappeared: the buffer declarations, the binding, the watermark, the inner loop, and the decision about what to write during warm-up. Note what appeared: two null guards, because the script now says out loud that the first twenty bars have no envelope, which the MQL version also knew and expressed by leaving a buffer at whatever it happened to contain.