ES · EN

Guides · FARO team · September 19, 2026 · 9-minute read

Sealing parameters in advance: how to make your forward test verifiable

A trade history does not say which configuration produced it. If the parameters were touched along the way, the history is still real and stops meaning what it seems to mean. This guide explains how to commit to a configuration before you start, so that anyone can check it without having to trust you.

A track record whose parameters move is not a track record

Imagine an Expert Advisor that has been trading for six months. In month three it was doing badly and its lot size was lowered. In month four its stop was moved by 50 points. In month five its trailing stop, which had been off, was switched on.

At the end of the six months there is a trade history. Every trade is real, none has been deleted, no figure is made up. And the history is false, because it is not the history of one system: it is that of three or four different systems presented as one. Each adjustment was made after seeing the results, so the decisions in the good months build in information that did not exist when the test began.

Nobody has lied about any specific figure. It is the same trap as publishing five systems, withdrawing the four that are doing badly and keeping the one that survived —survivorship bias—, but one level deeper: inside a single system, in its parameters.

And it is undetectable from outside. Whoever looks at the history sees trades; the parameters that produced them are nowhere to be found.

What follows is a way to make them findable, and to make it unnecessary to believe you.

Why writing them down is not enough

The intuitive answer is to store the configuration: a PDF with the parameters and the date, or a row in a database, or a hash stored on a server.

None of the three solves the problem, and they all fail for the same reason. A stored piece of data is a claim made by whoever stored it. To believe it you have to trust them: that the PDF is from the date it says, that the row has not been updated, that the hash on their server is the one they computed back then. If the question was “can I trust this?”, the answer cannot be “trust this other thing”.

A stored hash is a claim. A recomputable hash is a check.

The difference is not semantic. If the hash is recomputed from data you can read, believing it does not require trusting anyone: you run the calculation and it matches, or it doesn’t. An integrity system that requires trust in whoever operates it is not an integrity system. It is the same standard FARO seals its signals by, and the reason the methodology publishes the exact format of the text that is hashed instead of asking you to take the result on faith.

The three rules

That gives the three conditions. All three are constraints on what the code cannot do, not features:

  1. The string that is hashed is built only from values the user sees in the parameters dialog. No account number, no broker, no time: those would change the hash between two terminals with the same configuration, and the seal would stop meaning “these parameters”.
  2. Plain SHA-256, without a key and without a salt. With a key it becomes an HMAC and is no longer reproducible with sha256sum. A fingerprint that only the author can recompute is a claim again.
  3. The exact text is written to a file, byte for byte. Without this, rule 2 is a promise: the reader would have nowhere to get the text to hash it.

The third is the one that gets forgotten, and it is the one that holds up the other two.

The canonical string

One field per line, in a fixed order, with its number in front. For the example configuration, the exact text that is hashed is these eight lines:

sello-config-v1
01_lotaje=3FB999999999999A
02_magic=246810
03_riesgo_pct=3FF8000000000000
04_stop_puntos=350
05_periodo=15
06_trailing=1
07_etiqueta=produccion

160 bytes in UTF-8, with a line break after the last line too. SHA-256:

6f55a362e479dc9c514228ac72e0be32f5be273f490a32af8b96f9d6f79cfad2

Three formatting decisions that are not cosmetic:

And the lot size does not say 0.10. That comes next.

The trap that lets the failure through instead of warning

The natural way to put a double into a string is DoubleToString(v). Don’t do it in a hash, and the reason is that it fails in the dangerous direction.

DoubleToString uses 8 decimal places by default and rounds. So:

ValuePrinted textActual bits
1.51.500000003FF8000000000000
1.5000000011.500000003FF800000044B830

Two different configurations with the same hash. For a seal that is the worst possible failure, because it does not raise a false alarm: it lets a changed parameter through without saying anything. A guard that errs by warning is annoying; one that errs by keeping quiet is worse than not having one, because it also gives you confidence.

Adding decimal places does not fix it, it moves it somewhere else. At 17 digits you start printing the noise of the binary representation —0.3 comes out as 0.29999999999999999 and 0.1+0.2 as 0.30000000000000004, which are two different double values and the same number to any human—. And a new problem appears: the decimal separator. In MQL5 it is always a point, but whoever reimplements the verification in C with a Spanish locale will get 1,500000 and another hash. The risk is outside, which is where you cannot control it.

The solution is never to convert to decimal at any point. The 8 bytes of the double are hashed, in hexadecimal:

union DobleEnBits
  {
   double            valor;
   ulong             bits;
  };

string DobleCanonico(const double valor)
  {
   DobleEnBits u;
   u.valor = valor;
   return StringFormat("%016I64X", u.bits);
  }

Sixteen characters, with no rounding, no separator and no locale. 0.10 is 3FB999999999999A and 0.11 is 3FBC28F5C28F5C29: exact by construction, not by sufficient precision.

And a note for whoever reimplements this, because it is the most likely error and it is silent. The union reads memory in little-endian, but %016I64X does not print bytes: it prints the number, with the most significant digit first. The two operations cancel out, so the string comes out with the bit pattern in MSB→LSB order on any architecture. The Python equivalent is therefore big-endian: struct.pack('>d', valor).hex().upper(). With '<d' you get the same eight bytes reversed, and another hash.

The hash and the file come from the same array

Two details of the calculation that are worth more than their size.

The terminator. StringToCharArray with count = -1 adds the final 0x00, and that byte would go into the hash: the result would not match that of any external tool on the same text. It is trimmed explicitly.

A single conversion. The same function returns the bytes that are hashed and the ones written to the file. If the text were converted twice —once for each— there would be two conversions that can differ, and sha256sum on the file might not give the sealed hash. A single conversion is the only way for the file and the fingerprint to be unable to diverge: it stops being something you have to check and becomes something that cannot happen.

bool BytesCanonicos(const string texto, uchar &fuera[])
  {
   int n = StringToCharArray(texto, fuera, 0, -1, CP_UTF8);
   if(n <= 0)
      return false;
   if(fuera[n - 1] == 0)   // el terminador NO entra en el hash
      n--;
   return (ArrayResize(fuera, n) == n);
  }

string Sha256Hex(const uchar &datos[])
  {
   uchar clave[];    // SHA-256 no lleva clave; el array va vacio a proposito.
   uchar salida[];   // Con clave se convertiria en un HMAC y dejaria de ser
                     // reproducible con `sha256sum`, que es justo lo que se busca.

   if(CryptEncode(CRYPT_HASH_SHA256, datos, clave, salida) != 32)
      return "";     // SHA-256 son 32 bytes SIEMPRE; otra cosa es un error

   string hex = "";
   for(int i = 0; i < 32; i++)
      hex += StringFormat("%02x", salida[i]);
   return hex;
  }

Sha256Hex returns "" if something fails, and the caller has to check it: an empty hash compared with an empty hash is equal, and the seal would pass without having calculated anything.

This is what the terminal prints when sealing:

Sello: sha256 = 6f55a362e479dc9c514228ac72e0be32f5be273f490a32af8b96f9d6f79cfad2
Sello: cadena canonica en canonica_SelloDeConfiguracion.txt (160 bytes).
Sello: comprueba el hash con -> sha256sum canonica_SelloDeConfiguracion.txt
Sello: configuracion sellada en sello_SelloDeConfiguracion.txt

The second implementation

This is where the proof is, and it is not the MQL5 code.

This Python reimplementation does not read the file the Expert Advisor writes or its source code: it rebuilds the canonical string from the parameters and hashes it. It only uses the standard library, so it runs on any Python 3 without installing anything.

def doble(valor):
    """Los 8 bytes del double en hexadecimal, digito mas significativo primero."""
    return struct.pack(">d", valor).hex().upper()


def canonica(lotaje, magic, riesgo_pct, stop_puntos, periodo, trailing, etiqueta):
    """El texto exacto que se hashea: un campo por linea, en orden fijo."""
    # Un salto de linea dentro de un valor partiria el campo en dos y haria que
    # dos configuraciones distintas se hashearan igual. Se sustituye, no se borra.
    etiqueta = etiqueta.replace("\r", " ").replace("\n", " ")

    campos = [
        FORMATO,                                    # subirlo invalida los sellos previos
        f"01_lotaje={doble(lotaje)}",
        f"02_magic={magic}",
        f"03_riesgo_pct={doble(riesgo_pct)}",
        f"04_stop_puntos={stop_puntos}",
        f"05_periodo={periodo}",                    # el entero del timeframe: M15 -> 15
        f"06_trailing={1 if trailing else 0}",
        f"07_etiqueta={etiqueta}",
    ]

    # Separador LF, y LF tambien al final del ultimo campo. Nunca CRLF: cada
    # 0x0D de mas es un byte que entra en el hash.
    return "".join(campo + "\n" for campo in campos)

And when you run it:

$ python3 sello_config.py
sha256 = 6f55a362e479dc9c514228ac72e0be32f5be273f490a32af8b96f9d6f79cfad2
bytes = 160

That hash is the same one the terminal prints. And that proves something stronger than “the code works”: it proves that the format is fully specified. If reimplementing it from the description had required looking at the source to get it right, the format would have a gap and the fingerprint would not really be reproducible by a third party — it would be reproducible by whoever had the code.

Two independent implementations that match is the property we were after. The hash is just the way to check it.

Checked in MetaTrader 5 on Wine on macOS: CryptEncode(CRYPT_HASH_SHA256, …) returns standard SHA-256, not a variant. It is a fact verified on a real terminal, not an assumption from the documentation.

Verification, in one command

It is what the reader will do on day one, and it needs no code:

$ shasum -a 256 canonica_SelloDeConfiguracion.txt
6f55a362e479dc9c514228ac72e0be32f5be273f490a32af8b96f9d6f79cfad2  canonica_SelloDeConfiguracion.txt

On Linux it is sha256sum; on Windows, certutil -hashfile <fichero> SHA256 or Get-FileHash. Both Windows ones return uppercase and the program prints lowercase: compare case-insensitively.

That hash has to be the one on the SELLO line of the seal file. If it matches, the configuration that produced that history is the one that is sealed. If it doesn’t, something changed — and the seal file says when someone tried to start with another one.

The two encoding traps

This single command also validates the two failures that would break external verification without showing any symptom. Both are in the same place: how the file is written.

The first is the line break. FILE_TXT translates line breaks to the system convention, that is, CRLF on Windows. The canonical separators are LF, so the file would come out with an extra 0x0D for each field: 168 bytes instead of 160, and another hash. The program does its job, the file looks right opened in an editor, and sha256sum does not match without anything explaining why.

The second is the character set. In MQL5 there is no UTF-8 text flag: FILE_UNICODE writes UTF-16 and FILE_ANSI writes ANSI. Neither works — with an accent in a text parameter, the file would have different bytes from the ones that were hashed.

The solution covers both: it is written with FILE_WRITE | FILE_BIN and FileWriteArray, and what is written is the same byte array that was hashed. FILE_BIN translates nothing and adds no BOM.

int h = FileOpen(ruta, FILE_WRITE | FILE_BIN);
...
uint escritos = FileWriteArray(h, datos, 0, WHOLE_ARRAY);

And here is the detail needed to really test it: with an ASCII label the encoding failure is invisible, because UTF-8 and ANSI give the same bytes. You have to seal with an accent. With etiqueta = producción the file goes up to 161 bytes —the ó takes two, C3 B3— and the hash becomes:

f4f5c0b32ceabc1c902e084d3745ee8b23d76d16bc11bce43dc06cfe44ac59d5

If it were in ANSI it would be 160 bytes and another hash. That extra byte is the whole proof.

What happens when it doesn’t match

The program records the attempt and refuses to run. The seal file ends up like this:

# Sello de configuracion. Formato: sello-config-v1
# Una linea por evento, en orden de llegada. Nunca se reescribe.
# SELLO   = configuracion vigente
# RECHAZO = intento de arrancar con otra configuracion
#
# tipo | fecha y hora del servidor | programa | sha256
SELLO   | 2026.08.31 20:20:17 | SelloDeConfiguracion | f4f5c0b32ceabc1c902e084d3745ee8b23d76d16bc11bce43dc06cfe44ac59d5
RECHAZO | 2026.08.31 20:37:29 | SelloDeConfiguracion | ebc11930a2b41efda99f8908cf1d62a3b8570c156bd66d55bf33c7a1c6671466

Three decisions, and all three are the heart of the matter:

The rejection is recorded before failing. A seal that blocks but leaves no history is of little use: whoever restarts three times with three different configurations leaves no trace. With the RECHAZO line, the file tells what was attempted and when. The history is the point; the block is only the mechanism.

The sealed canonical string is not overwritten. The rejected attempt is dumped to a separate file. Overwriting the sealed one would destroy exactly what allows the current seal to be verified, and it would do so precisely at the moment it is needed most.

INIT_FAILED is returned, not INIT_PARAMETERS_INCORRECT. This is the one that matters. INIT_PARAMETERS_INCORRECT reopens the parameters dialog, and that invites adjusting them until the system starts: it turns the seal into an obstacle you get around by trial and error. But the configuration is not wrong: it is changed. And if it has changed, it is no longer the same test. The right answer is not to fix the parameters until they fit — it is to recognize that it is another test, archive the previous seal and seal again with a new date, leaving the earlier attempt in view. INIT_FAILED unloads the program from the chart and forces that conscious decision instead of offering a way back.

What a seal does not prove

It is worth saying as clearly as the rest, because a verification mechanism invites concluding more than it supports.

“And my parameters, are they exposed?”

It is the first reasonable objection, and it has an answer because the hash and the text are published at different times.

To seal, you only need to publish the hash: 64 characters that reveal nothing. The canonical string —which does contain the parameters in the clear— stays on your machine and is handed over when the time comes: when the test period closes, or to whoever has to audit, and not before.

And it works in that order precisely because the hash is a prior commitment. Publishing it first is what makes the later disclosure mean something: you cannot choose afterwards which parameters to say you had. The other way round —publishing the parameters and then the hash— proves nothing at all.

It is the only sequence that works, and it is worth understanding why before reversing it for convenience.

Where the hash is published

A practical question remains: publish the hash, but where?

The property needed is just one: that you are not the one who sets the date. A text file on your disk with the hash and today’s date is worth nothing, because tomorrow you can write another one with today’s date. The hash has to be recorded by something you don’t control.

And that requires no infrastructure. Any place that sets the date on its own will do: a post on a forum, an email to the client you work for —the mail server dates it—, a commit pushed to a public repository, a message in the description of a product or a signal. None of those places has to understand what a seal is or cooperate in any way: it only has to have set the date before the test began.

Which one you choose changes how easy the date is to dispute, not how the mechanism works. An email can be questioned by whoever received it; a public post on a forum cannot. But even the email is qualitatively different from a file on your disk, because there is a copy that is not in your hands.

That is the only piece the seal does not give itself, and that is why it is worth saying out loud instead of leaving it implicit.

If the program you seal publishes signals on FARO, that part is already solved by the site: agents publish with their own profile and under the responsibility of a verified operator, and each signal is sealed with its date in a public registry that whoever issues it does not control.

The code

It is published to be copied and adapted. If you adapt the fields, raise the format version on the first line: it is what keeps two equal hashes from meaning different configurations.

Both hashes in this guide can be reproduced by running them.

Why we wrote this

FARO publishes the complete track record of every analyst and every automated system, losses included, because a track record where you choose what to show means nothing. Sealing parameters is the same problem one level deeper, and the solution has the same shape: commit beforehand, and make the check not depend on being believed.

If you want to apply that standard to any analyst or channel —here or elsewhere—, the practical list is in how to tell if a trader is reliable: 7 checks.