A zero-shot model (IBM Granite TTM-R2) in MT5 via ONNX: export pitfall, 0.86 ms per forecast, what it can forecast

I wanted to know whether one of the new pre-trained time-series models can run inside MetaTrader 5 itself, without Python, DLLs or WebRequest, so that it also works in the Strategy Tester. It can. These are my notes on IBM's Granite TTM-R2 (Tiny Time Mixers, Apache-2.0), with the numbers I measured.

The model. I used the 512-96 variant of ibm-granite/granite-timeseries-ttm-r2: 512 past values in, 96 forecast steps out, 805,280 parameters. Exported to ONNX (opset 17) it is 3.3 MB, so it fits in an .ex5 as a resource.

Export pitfall. The legacy exporter, torch.onnx.export with dynamo=False, mistranslates Tensor.unfold(dimension=-2) inside the patching step. It keeps the negative axis in Unsqueeze/Concat after a dimension has been added, so onnxruntime refuses the file with "[ShapeInferenceError] Incompatible dimensions for matrix multiplication" in the first patch-mixer MatMul. TTM's patches do not overlap (stride = patch length = 64), so for the export I replaced the patching with reshape + permute. The weights are unchanged and the PyTorch outputs are identical before and after.

MQL5 side. The float arrays can be passed straight to OnnxRun; the shapes just have to be set once after loading:

// Granite TTM-R2 (512-96): 512 past values in, 96 forecast steps out, float32.
#resource "ttm_r2_512_96.onnx" as uchar TtmModel[]

long  g_ttm = INVALID_HANDLE;
float g_in[], g_out[];

bool TtmOpen()
{
   g_ttm = OnnxCreateFromBuffer(TtmModel, ONNX_DEFAULT);
   if(g_ttm == INVALID_HANDLE) { PrintFormat("OnnxCreateFromBuffer failed, err=%d", GetLastError()); return false; }
   const ulong in_shape[]  = {1, 512, 1};
   const ulong out_shape[] = {1, 96, 1};
   if(!OnnxSetInputShape(g_ttm, 0, in_shape) || !OnnxSetOutputShape(g_ttm, 0, out_shape))
   {
      PrintFormat("OnnxSetInputShape/OnnxSetOutputShape failed, err=%d", GetLastError());
      OnnxRelease(g_ttm);
      g_ttm = INVALID_HANDLE;
      return false;
   }
   ArrayResize(g_in, 512);
   ArrayResize(g_out, 96);
   return true;
}

// Per-hour high-low range forecast for the next 96 hours from the last 512 closed H1 bars.
// The model expects a standardised series: standardise on the way in, undo it on the way out.
bool TtmRangeForecast(const string sym, double &range[])
{
   MqlRates r[];
   ArraySetAsSeries(r, false);
   if(g_ttm == INVALID_HANDLE || CopyRates(sym, PERIOD_H1, 1, 512, r) != 512) return false;
   double x[512], mu = 0.0, var = 0.0;
   for(int i = 0; i < 512; i++)
   {
      double rng = (r[i].low > 0.0) ? MathLog(r[i].high / r[i].low) : 0.0;
      x[i] = MathLog(MathMax(rng, 1e-5));          // floor for flat bars; better: a low quantile of your own data
      mu += x[i];
   }
   mu /= 512;
   for(int i = 0; i < 512; i++) var += (x[i] - mu) * (x[i] - mu);
   double sd = MathSqrt(var / 512) + 1e-6;
   for(int i = 0; i < 512; i++) g_in[i] = (float)((x[i] - mu) / sd);
   if(!OnnxRun(g_ttm, ONNX_DEFAULT, g_in, g_out)) { PrintFormat("OnnxRun failed, err=%d", GetLastError()); return false; }
   ArrayResize(range, 96);
   for(int k = 0; k < 96; k++) range[k] = MathExp((double)g_out[k] * sd + mu);   // log(high/low) of hour k + 1
   return true;
}

Does MT5 give the same numbers as Python? Not bit for bit, but close: the largest difference against PyTorch was 4.8e-7 on the standardised scale over three test series (the tester ships onnxruntime 1.25.1, Python had 1.31.0). Repeated runs inside MT5 return exactly the same values.

Speed. In the Strategy Tester one forecast takes 0.86 ms (median of 200 runs, CPU), and loading the model about 0.13 s. Ten years of H1 bars is about one minute of inference in total. Once per closed bar is fine; once per tick is not.

Input. The model expects each series to be standardised, and the 512-96 variant is meant for minute to hourly data (daily and weekly data have their own r2.1 variants). I feed it the log of each H1 bar's high-low range, standardised over the 512-bar window, and undo the standardisation on the output. A small floor avoids log(0) on flat bars; a low quantile of your own data works better than a fixed number.

What it can forecast. On 7 symbols (H1, 2015 to 2026) I compared its range forecasts with ATR and with two stronger simple baselines, a HAR model and an hour-of-day × level model. The error is the mean squared error of the log high-low range after calibration, shown relative to ATR. The chart is the part after the model's pre-training data, 2025–2026, 4 hours ahead:

4-hour range forecast error: zero-shot TTM-R2 vs HAR and hour x level, 7 symbols, 2025-2026

  • Against ATR the error is about 20 to 50% smaller, but ATR ignores the time of day, so that is an easy win.
  • Against the best simple baseline, 4 hours ahead in 2025–2026 (outside the model's pre-training period), it won on 6 of 7 symbols with a Diebold-Mariano t below −2. The median error ratio was 0.955, so about 4.5% smaller error: a small but consistent edge.
  • Gold was the exception: HAR was better there.
  • 24 hours ahead there was almost no difference. The useful range is a few hours.
  • Direction is a different story. Trading rules that used the forecast for direction did not pass my tests, so I would use it for volatility only: stop distance, position size, or skipping unusually wild hours.

Two smaller things. Calling ExpertRemove() inside OnInit makes the tester log "OnInit failed"; stop on the first OnTick instead. And Apache-2.0 asks you to give users a copy of the license, so if you distribute an EA with the model inside, plan how to show or write out that text.

If you have tried other foundation models (Chronos, TimesFM, Moirai) inside MT5, I would like to hear how they compared.