[MQL5 · Pine Script]

Repaint Auditor

Measures whether an indicator changes the values it already showed on CLOSED bars, and keeps that separate from the normal movement of a forming bar.

Published on 2 August 2026 · By SuaVar

The full source is right above and you can copy it. Downloading the file needs a free account.

//+------------------------------------------------------------------+
//|                                              RepaintAuditor.mq5   |
//|                                          https://suavar.com       |
//+------------------------------------------------------------------+
#property copyright "SuaVar"
#property link      "https://suavar.com"
#property version   "1.00"
#property description "Measures whether a custom indicator changes the values it already showed on"
#property description "bars that have CLOSED, and reports that separately from the normal movement"
#property description "that happens while a bar is still forming."
#property description ""
#property description "Works out of the box: with no indicator name it audits a built-in SMA(14),"
#property description "which is also the sanity check (a closed SMA bar can never change)."
#property description ""
#property description "Educational tool. It does not trade, it does not give signals and it does not"
#property description "tell anyone what to buy or sell."

#property indicator_separate_window
#property indicator_buffers 1
#property indicator_plots   1

#property indicator_label1  "Bars changed after close"
#property indicator_type1   DRAW_LINE
#property indicator_color1  clrTomato
#property indicator_width1  2

//--- Constantes ---------------------------------------------------------------
//--- Periodo de la media integrada que se audita cuando no se indica ningun indicador.
#define SELF_CHECK_PERIOD 14

//--- Cuantas veces se vuelve a intentar abrir un handle que fallo, ANTES de rendirse.
//---
//--- ⚠️ Antes se reintentaba una vez por vela, indefinidamente. Parecia inofensivo y no lo era: cada
//--- iCustom fallido escribe varias lineas de error en el log del terminal, asi que un nombre mal
//--- escrito convertia UN error en uno POR VELA — miles en una pasada del probador. Un handle no se
//--- arregla solo: si el nombre esta mal seguira mal, y si el usuario lo corrige MetaTrader vuelve a
//--- llamar a OnInit de todas formas. Los reintentos solo cubren el caso raro de que compile el
//--- indicador que falta sin quitar este del grafico, y para eso bastan unos pocos.
#define OPEN_RETRIES 3

//--- Tope del parametro "velas cerradas bajo auditoria". Ver la comprobacion de OnInit.
#define MAX_AUDIT_BARS 5000

//--- Inputs -------------------------------------------------------------------
//--- ⚠️ El valor por defecto es VACIO, y eso NO es un descuido.
//---
//--- Apuntaba a "Examples\\Custom Moving Average", que viene con MetaTrader pero SIN COMPILAR: en un
//--- terminal recien instalado ese .ex5 no existe. Cualquiera que lo instalara y pulsara OK sin tocar
//--- nada se encontraba con un indicador que no cargaba, y la validacion automatica de MQL5 —que corre
//--- justamente en un terminal limpio y con los parametros por defecto— lo rechazo por eso.
//---
//--- Vacio significa "audita una MEDIA INTEGRADA del terminal" (ver OpenAudited). No hace falta ningun
//--- fichero, funciona en cualquier instalacion, y ademas es la PRUEBA DE CORDURA que la propia
//--- documentacion recomienda hacer primero: la media de una vela cerrada no puede cambiar, asi que el
//--- contador tiene que quedarse en 0. Si no se queda en 0, la tolerancia esta demasiado apretada.
input string InpIndicatorName = "";     // Indicator to audit (empty = built-in SMA(14), a self-check)
input int    InpBufferIndex   = 0;      // Buffer index of that indicator
input int    InpParamCount    = 0;      // How many of the parameters below to pass (0 = its defaults)
input double InpParam1        = 0;      // Parameter 1
input double InpParam2        = 0;      // Parameter 2
input double InpParam3        = 0;      // Parameter 3
input double InpParam4        = 0;      // Parameter 4
input double InpTolerancePct  = 0.001;  // Change tolerance (%)
input int    InpAuditBars     = 100;    // Closed bars kept under audit

//--- State --------------------------------------------------------------------
double  ChangedBuffer[];

int      g_handle = INVALID_HANDLE;

// Una entrada por vela cerrada OBSERVADA EN VIVO: su hora de apertura, el valor que el indicador
// mostraba justo al cerrarla, y si ya se contó un cambio (para no contarlo dos veces).
datetime g_time[];
double   g_value[];
bool     g_flagged[];
int      g_stored = 0;

// Hora de apertura de la ULTIMA vela vista, no el numero de velas. Ver el comentario de OnCalculate:
// contar velas confunde "ha nacido una vela" con "se ha cargado mas historico".
datetime g_lastBarTime  = 0;
int      g_observed     = 0;
int      g_changed      = 0;
double   g_worstChange  = 0.0;
double   g_worstSwing   = 0.0;
double   g_devMin       = 0.0;
double   g_devMax       = 0.0;
datetime g_devBar       = 0;
// Ultima vela en la que se reintento abrir el handle, y cuantos reintentos quedan. Ver OPEN_RETRIES.
datetime g_retryBar     = 0;
int      g_retriesLeft  = OPEN_RETRIES;

// Capacidad REAL de los tres arrays paralelos, no la pedida. Ver OnInit.
int      g_capacity     = 0;

// El rotulo solo se rehace cuando hay algo nuevo que decir. Ver el final de OnCalculate.
bool     g_dirty        = true;

//+------------------------------------------------------------------+
//| Abre el handle del indicador auditado.                            |
//|                                                                   |
//| Sin nombre se audita una media integrada: iMA vive DENTRO del      |
//| terminal, asi que no depende de ningun .ex5 y no puede faltar.     |
//|                                                                   |
//| iCustom no admite una lista de parametros de longitud variable, de |
//| ahi las ramas: es la forma habitual de cubrir varios casos sin     |
//| obligar al usuario a tocar el codigo.                              |
//+------------------------------------------------------------------+
int OpenAudited()
  {
   if(StringLen(InpIndicatorName) == 0)
      return(iMA(_Symbol, _Period, SELF_CHECK_PERIOD, 0, MODE_SMA, PRICE_CLOSE));

   switch(InpParamCount)
     {
      case 0:  return(iCustom(_Symbol, _Period, InpIndicatorName));
      case 1:  return(iCustom(_Symbol, _Period, InpIndicatorName, InpParam1));
      case 2:  return(iCustom(_Symbol, _Period, InpIndicatorName, InpParam1, InpParam2));
      case 3:  return(iCustom(_Symbol, _Period, InpIndicatorName, InpParam1, InpParam2, InpParam3));
      default: return(iCustom(_Symbol, _Period, InpIndicatorName, InpParam1, InpParam2, InpParam3, InpParam4));
     }
  }

//+------------------------------------------------------------------+
//| Nombre corto de lo que se esta auditando, para la cabecera.       |
//+------------------------------------------------------------------+
string AuditedLabel()
  {
   if(StringLen(InpIndicatorName) == 0)
      return(StringFormat("SMA(%d)", SELF_CHECK_PERIOD));

   string leaf  = InpIndicatorName;
   int    slash = StringLen(leaf) - 1;
   while(slash >= 0 && StringGetCharacter(leaf, slash) != '\\')
      slash--;
   if(slash >= 0)
      leaf = StringSubstr(leaf, slash + 1);
   return(leaf);
  }

//+------------------------------------------------------------------+
//| Lee el valor del indicador auditado en una posicion de SERIE      |
//| (0 = vela en formacion). Devuelve false si aun no hay dato.       |
//+------------------------------------------------------------------+
bool ReadAudited(const int shift, double &out)
  {
   double tmp[1];
   if(CopyBuffer(g_handle, InpBufferIndex, shift, 1, tmp) != 1)
      return(false);
   if(tmp[0] == EMPTY_VALUE || !MathIsValidNumber(tmp[0]))
      return(false);
   out = tmp[0];
   return(true);
  }

//+------------------------------------------------------------------+
int OnInit()
  {
   SetIndexBuffer(0, ChangedBuffer, INDICATOR_DATA);
   ArraySetAsSeries(ChangedBuffer, false);
   PlotIndexSetDouble(0, PLOT_EMPTY_VALUE, EMPTY_VALUE);

//--- El tope de arriba no es decorativo: sin el, un valor absurdo pide una reserva de memoria absurda,
//--- y auditar mas velas de las que caben en la sesion no aporta nada.
   if(InpAuditBars < 1 || InpAuditBars > MAX_AUDIT_BARS || InpTolerancePct < 0.0)
     {
      Print("RepaintAuditor: invalid inputs. 'Closed bars kept under audit' must be 1..",
            MAX_AUDIT_BARS, " and the tolerance cannot be negative.");
      return(INIT_PARAMETERS_INCORRECT);
     }

//--- ⚠️ Un handle que no se puede crear NO puede tumbar el arranque.
//---
//--- Con un nombre puesto, el indicador auditado es un archivo del usuario que puede no existir, no
//--- estar compilado o llamarse de otra forma. Devolver INIT_FAILED ahi deja un indicador muerto y sin
//--- explicacion. Arranca siempre y dice en su cabecera lo que le falta.
//---
//--- Sin nombre (el defecto) esto no puede fallar: iMA es del terminal.
   g_handle = OpenAudited();
   g_retriesLeft = OPEN_RETRIES;
   if(g_handle == INVALID_HANDLE)
      Print("RepaintAuditor: \"", InpIndicatorName,
            "\" could not be loaded. Check the path (relative to MQL5\\Indicators, no extension) ",
            "and the parameter count. Leave the name empty to audit a built-in SMA instead.");

//--- ⚠️ Se manda en la capacidad REAL, no en la pedida. ArrayResize puede devolver menos de lo que se
//--- le pide, y todo lo que indexara por InpAuditBars escribiria entonces fuera de rango: "array out of
//--- range" es de los rechazos clasicos de la validacion y, sobre todo, es un fallo que revienta en la
//--- maquina del usuario y no en la de quien lo escribe.
   g_capacity = ArrayResize(g_time, InpAuditBars);
   g_capacity = MathMin(g_capacity, ArrayResize(g_value,   InpAuditBars));
   g_capacity = MathMin(g_capacity, ArrayResize(g_flagged, InpAuditBars));
   if(g_capacity < 0) g_capacity = 0;

   IndicatorSetString(INDICATOR_SHORTNAME, "Repaint audit: waiting for live bars");
   IndicatorSetInteger(INDICATOR_DIGITS, 0);
   return(INIT_SUCCEEDED);
  }

//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   if(g_handle != INVALID_HANDLE)
     {
      IndicatorRelease(g_handle);
      g_handle = INVALID_HANDLE;
     }
  }

//+------------------------------------------------------------------+
//| Guarda una vela recien cerrada, descartando la mas antigua.       |
//+------------------------------------------------------------------+
void StoreClosed(const datetime openTime, const double value)
  {
   if(g_capacity < 1) return;

   if(g_stored < g_capacity)
     {
      g_time[g_stored]    = openTime;
      g_value[g_stored]   = value;
      g_flagged[g_stored] = false;
      g_stored++;
      return;
     }
   for(int i = 1; i < g_capacity; i++)
     {
      g_time[i-1]    = g_time[i];
      g_value[i-1]   = g_value[i];
      g_flagged[i-1] = g_flagged[i];
     }
   g_time[g_capacity-1]    = openTime;
   g_value[g_capacity-1]   = value;
   g_flagged[g_capacity-1] = false;
  }

//+------------------------------------------------------------------+
//| Localiza la posicion de SERIE de una vela por su hora de apertura. |
//|                                                                    |
//| Se busca por HORA y no por indice a proposito: el indice de una    |
//| vela cambia si el terminal carga mas historico, y comparar contra  |
//| la vela equivocada daria un falso positivo — justo lo que esta     |
//| herramienta no se puede permitir.                                  |
//+------------------------------------------------------------------+
int SeriesShiftOf(const datetime openTime, const datetime &time[], const int rates_total)
  {
   int limit = g_capacity + 50;
   if(limit > rates_total) limit = rates_total;
   for(int shift = 0; shift < limit; shift++)
     {
      int idx = rates_total - 1 - shift;
      if(idx < 0) break;
      if(time[idx] == openTime) return(shift);
     }
   return(-1);
  }

//+------------------------------------------------------------------+
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[])
  {
   if(rates_total < 2) return(0);

//--- Sin handle util no hay nada que auditar, pero el indicador sigue vivo y lo dice. Se reintenta
//--- como mucho OPEN_RETRIES veces y una vez por VELA: cada intento fallido escribe en el log, asi que
//--- reintentar para siempre convierte un error en un torrente. Ver el comentario de OPEN_RETRIES.
   if(g_handle == INVALID_HANDLE)
     {
      if(g_retriesLeft > 0 && g_retryBar != time[rates_total-1])
        {
         g_retryBar = time[rates_total-1];
         g_retriesLeft--;
         g_handle = OpenAudited();
        }
      if(g_handle == INVALID_HANDLE)
        {
         IndicatorSetString(INDICATOR_SHORTNAME,
                            StringFormat("Repaint: cannot load \"%s\" — clear it to audit a built-in SMA",
                                         InpIndicatorName));
         return(rates_total);
        }
     }

   if(BarsCalculated(g_handle) < rates_total) return(prev_calculated);

//--- La linea dibujada es el contador acumulado. En historico no hay nada que
//--- auditar (solo se ven valores ya definitivos), asi que vale 0 hasta que el
//--- indicador empieza a observar en vivo.
   int start = (prev_calculated > 0) ? prev_calculated - 1 : 0;
   for(int i = start; i < rates_total; i++)
      ChangedBuffer[i] = (double)g_changed;

//--- Movimiento intrabar de la vela en formacion.
   double live = 0.0;
   if(ReadAudited(0, live))
     {
      if(g_devBar != time[rates_total-1])
        {
         g_devBar = time[rates_total-1];
         g_devMin = live;
         g_devMax = live;
        }
      else
        {
         if(live < g_devMin) g_devMin = live;
         if(live > g_devMax) g_devMax = live;
        }
     }

//--- Primera pasada: solo se anota donde estamos. El historico NO se audita porque
//--- la informacion necesaria —que mostraba el indicador en su momento— ya no existe
//--- ahi; solo quedan valores definitivos.
   if(g_lastBarTime == 0)
     {
      g_lastBarTime = time[rates_total-1];
      return(rates_total);
     }

//--- Se compara la HORA de la ultima vela, no el numero de velas.
//---
//--- `rates_total` crece por dos motivos distintos: porque nace una vela al final, o
//--- porque el terminal carga mas historico, que se añade por el otro extremo. Contar
//--- velas mezcla los dos, y entonces una carga de historico se toma por un cierre en
//--- vivo. No genera falsos positivos (esa vela ya tenia su valor definitivo, asi que
//--- nunca "cambia"), pero INFLA el numero de velas observadas y hace que un veredicto
//--- limpio parezca mas respaldado de lo que esta. En una herramienta cuyo unico
//--- producto es un veredicto, eso es peor que un fallo ruidoso.
   if(time[rates_total-1] > g_lastBarTime)
     {
      g_lastBarTime = time[rates_total-1];

      int closedIdx = rates_total - 2;
      double closedValue = 0.0;
      if(ReadAudited(1, closedValue))
        {
         //--- 1) Re-verificar lo que ya dabamos por cerrado.
         for(int s = 0; s < g_stored; s++)
           {
            if(g_flagged[s]) continue;
            int shift = SeriesShiftOf(g_time[s], time, rates_total);
            if(shift <= 0) continue; // -1 = no localizada; 0 = la vela viva, no se audita

            double now = 0.0;
            if(!ReadAudited(shift, now)) continue;

            // Denominador SIMETRICO: el mayor de los dos valores, no solo el antiguo. Muchos
            // indicadores usan 0.0 para "aqui no hay nada" (zigzags, fractales, flechas), asi que
            // dividir solo por el valor antiguo daria cifras astronomicas la primera vez que una
            // vela pasa de vacia a tener valor — correcto de fondo, ilegible en pantalla.
            double scale = MathMax(MathMax(MathAbs(g_value[s]), MathAbs(now)), 1e-10);
            double change = MathAbs(now - g_value[s]) / scale * 100.0;
            if(change > InpTolerancePct)
              {
               g_flagged[s] = true;
               g_changed++;
               if(change > g_worstChange) g_worstChange = change;
              }
           }

         //--- 2) Guardar lo que el indicador dice de la vela que acaba de cerrar.
         StoreClosed(time[closedIdx], closedValue);
         g_observed++;

         if(g_devMax > g_devMin)
           {
            double swing = (g_devMax - g_devMin) / MathMax(MathAbs(closedValue), 1e-10) * 100.0;
            if(swing > g_worstSwing) g_worstSwing = swing;
           }

         ChangedBuffer[rates_total-1] = (double)g_changed;
         g_dirty = true;
        }
     }

//--- ⚠️ El rotulo se rehace SOLO cuando hay algo nuevo que decir, no en cada tick.
//---
//--- Las cifras del veredicto unicamente cambian al cerrar una vela, asi que reconstruirlas por tick
//--- era trabajo tirado: dos StringFormat, un IndicatorSetString y un PlotIndexSetString por cada
//--- cotizacion. En un grafico en vivo se nota poco; en una pasada del probador sobre M1 —uno de los
//--- marcos con los que valida MQL5— son millones de formateos de cadena para pintar exactamente lo
//--- mismo, y el tiempo de validacion no es infinito.
   if(!g_dirty) return(rates_total);
   g_dirty = false;

//--- El veredicto vive en el nombre corto de la ventana: sin objetos graficos que
//--- limpiar y sin tapar el grafico del usuario.
//---
//--- ⚠️ El nombre corto SE TRUNCA en la cabecera de la subventana (se midio: cortaba a
//--- los 63 caracteres). Con la version larga el texto quedaba en "110 of 5" y parecia
//--- decir que habia 110 velas cambiadas de 5 observadas — imposible por la propia
//--- logica, y justo la clase de cifra que hace desconfiar de la herramienta entera.
//--- De ahi que aqui todo sea corto y que las CIFRAS vayan primero: si algo se corta,
//--- que sea la cola y no el dato.
   string leaf = AuditedLabel();

   string verdict;
   if(g_observed == 0)
      verdict = "waiting for live bars";
   else
      if(g_changed > 0)
         verdict = StringFormat("%d/%d changed | worst %.2f%%", g_changed, g_observed, g_worstChange);
      else
         verdict = StringFormat("clean over %d bars", g_observed);

   IndicatorSetString(INDICATOR_SHORTNAME, StringFormat("Repaint %s: %s", leaf, verdict));

//--- El detalle completo va al DATA WINDOW, que no tiene el limite de la cabecera. Es
//--- donde el usuario mira cuando quiere la cifra exacta, y ademas queda en la captura.
   PlotIndexSetString(0, PLOT_LABEL,
                      StringFormat("changed %d of %d | worst change %.4f%% | worst intrabar swing %.4f%%",
                                   g_changed, g_observed, g_worstChange, g_worstSwing));

   return(rates_total);
  }
//+------------------------------------------------------------------+

RepaintAuditor.mq5 · MetaTrader 5 · MQL5

Why the two behaviours are not the same thing

"Repainting" gets used for two very different things, and mixing them up is the reason the harmful one goes unnoticed.

Intrabar movement. Any indicator built on the current price moves while the bar is still forming and settles when the bar closes. This is normal, it is not a defect, and it is not what breaks a backtest. It only matters if you act on a signal before the bar has closed — and then it matters a great deal, because the signal you acted on may not be there at the close.

Change after the close. The value shown on a bar that has ALREADY closed turns out to be different later. This one invalidates a backtest, because the strategy is tested against numbers that were never available at the time. It is also nearly invisible in day-to-day use: reloading a chart only ever shows you the final values, never what was displayed when the decision would have been made.

The tool measures both, keeps them apart, and passes no judgement. Plenty of indicators change after the close by design and are perfectly legitimate — zigzags, fractals, and any pattern confirmed some bars after the fact. The point is not to label them: it is to make the behaviour visible so you know which one you are holding.

How the change after the close is detected

On historical bars only final values exist, so that change cannot be detected by looking backwards: the information needed is no longer there. The only way is to watch forwards. When a bar closes in real time the indicator records the value it was showing at that exact moment, together with the bar's open time; some bars later it asks again what that buffer now says about that same bar; and if the two differ by more than the tolerance, that bar changed after it had closed.

Bars are matched by open time, not by index. A bar's index shifts when the terminal loads more history, and comparing against the wrong bar would produce a false positive — exactly what a tool whose only product is a verdict cannot afford.

The comparison is relative and symmetric: it divides by the larger of the two values. That matters because plenty of indicators use 0 for "nothing here" — zigzags, fractals, arrows — and dividing by the old value alone would produce astronomical figures the first time a bar goes from empty to a value.

It runs with no setup, and that is the sanity check

Told nothing, it audits something that cannot repaint: the closing price on TradingView, a built-in moving average on MetaTrader. Neither needs an extra file, so it works on any fresh install — and more importantly, it is how you check the tool before trusting it. A closed bar's price never changes, so the counter must stay at zero. If it does not, the tolerance is too tight for that instrument and needs raising.

It is worth the minute it costs: a verdict of “clean” only means something once you have watched the tool return zero where zero is the right answer.

The two implementations are not a literal translation

In Pine you need varip to watch the live bar: a regular variable reverts on every tick recalculation and would be blind to how it evolves. In MQL5 globals persist between ticks on their own, so that part comes for free — but in exchange bars have to be matched by time, because rates_total grows both when a bar is born and when more history is loaded, and confusing the two inflates the number of bars observed.

Limitations, stated up front

It only audits forwards, in real time. It cannot audit history, and neither can anything else: the information required no longer exists there. Observations accumulate only while the indicator stays attached and reset when the chart or terminal restarts, so a verdict is worth exactly as much as the number of bars observed.

It reports behaviour, not intent. An indicator that changes after the close is not automatically broken — but a strategy backtested on one is not measuring what it appears to measure.

Educational

This is a measurement tool. It does not trade, it does not open or close positions, it does not produce buy or sell signals and it does not tell anyone what to trade.

Also published on: MQL5 Market

Educational material. None of this is financial advice or an investment recommendation: these are measurement and analysis tools, not buy or sell signals.

Repaint Auditor · SuaVar