Edgoro
Script · MQL5

MQL5 normalize price and lot size: tick size, volume step

Short answer

Round prices to the symbol's tick size with MathRound(price / tick) * tick, then NormalizeDouble(..., _Digits): NormalizeDouble() alone isn't enough where a tick is larger than a point. Round volumes down to SYMBOL_VOLUME_STEP, cap them at SYMBOL_VOLUME_MAX, and skip the trade when the result is below SYMBOL_VOLUME_MIN.

UsesSYMBOL_TRADE_TICK_SIZESYMBOL_VOLUME_STEPSYMBOL_VOLUME_MINSYMBOL_VOLUME_LIMITNormalizeDoubleMathFloor

The code

A script that sends no orders: it prints the chart symbol's price and volume rules in the Experts tab, then normalizes the price and volume you enter.

Save it as MQL5/Scripts/normalize-price-and-volume.mq5 and compile it in MetaEditor (F7): 0 errors, 0 warnings.

MQL5
#property script_show_inputs

input double InpLots   = 0.237; // Volume to normalize, lots
input double InpOffset = 0.37;  // Price to normalize: Bid plus this many ticks

// Price on the symbol's tick grid. NormalizeDouble(price, _Digits) alone isn't enough when a tick is bigger than a point.
double NormalizePrice(const string symbol, const double price)
  {
   double tick   = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
   int    digits = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS);
   if(tick <= 0.0)
      return NormalizeDouble(price, digits);
   return NormalizeDouble(MathRound(price / tick) * tick, digits);
  }

// Volume rounded DOWN to the step and capped at the maximum; 0 when it's below the minimum.
// Rounding a risk-based size up would risk more than planned, so this never does.
double NormalizeVolume(const string symbol, const double lots)
  {
   double step = SymbolInfoDouble(symbol, SYMBOL_VOLUME_STEP);
   double vmin = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN);
   double vmax = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MAX);
   if(step <= 0.0 || lots <= 0.0)
      return 0.0;
   // + 1e-9: 0.3 / 0.1 is 2.9999999999999996 in floating point, and MathFloor would drop a whole step
   double v = MathFloor(lots / step + 1e-9) * step;
   if(v > vmax)
      v = vmax;
   if(v < vmin)
      return 0.0;
   int decimals = (int)MathMax(0.0, MathCeil(-MathLog10(step) - 1e-9));   // 0.01 -> 2, 0.1 -> 1, 1 -> 0
   return NormalizeDouble(v, decimals);
  }

// Room left under SYMBOL_VOLUME_LIMIT (open positions plus pending orders in one direction), or -1 if no limit
double VolumeRoom(const string symbol, const bool buy)
  {
   double limit = SymbolInfoDouble(symbol, SYMBOL_VOLUME_LIMIT);
   if(limit <= 0.0)
      return -1.0;
   double used = 0.0;
   for(int i = PositionsTotal() - 1; i >= 0; i--)
      if(PositionGetTicket(i) > 0 && PositionGetString(POSITION_SYMBOL) == symbol &&
         (PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY) == buy)
         used += PositionGetDouble(POSITION_VOLUME);
   for(int i = OrdersTotal() - 1; i >= 0; i--)
     {
      if(OrderGetTicket(i) == 0 || OrderGetString(ORDER_SYMBOL) != symbol)
         continue;
      long type = OrderGetInteger(ORDER_TYPE);
      bool orderBuy = (type == ORDER_TYPE_BUY_LIMIT || type == ORDER_TYPE_BUY_STOP || type == ORDER_TYPE_BUY_STOP_LIMIT);
      if(orderBuy == buy)
         used += OrderGetDouble(ORDER_VOLUME_CURRENT);
     }
   return MathMax(0.0, limit - used);
  }

void OnStart()
  {
   double tick  = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE);
   double bid   = SymbolInfoDouble(_Symbol, SYMBOL_BID);
   double raw   = bid + InpOffset * tick;

   PrintFormat("%s: digits %d, point %s, tick size %s, tick value %.5f %s", _Symbol, _Digits,
               DoubleToString(_Point, _Digits), DoubleToString(tick, _Digits),
               SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE), AccountInfoString(ACCOUNT_CURRENCY));
   PrintFormat("Stops level %d points, freeze level %d points",
               (int)SymbolInfoInteger(_Symbol, SYMBOL_TRADE_STOPS_LEVEL), (int)SymbolInfoInteger(_Symbol, SYMBOL_TRADE_FREEZE_LEVEL));
   PrintFormat("Volume: min %.2f, step %.2f, max %.2f, limit %.2f (0 = none)",
               SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN), SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP),
               SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX), SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_LIMIT));
   PrintFormat("Price %s -> %s", DoubleToString(raw, _Digits + 3), DoubleToString(NormalizePrice(_Symbol, raw), _Digits));

   double lots = NormalizeVolume(_Symbol, InpLots);
   if(lots == 0.0)
      PrintFormat("Volume %.3f -> below this symbol's minimum: don't trade (or accept the larger risk knowingly)", InpLots);
   else
      PrintFormat("Volume %.3f -> %s", InpLots, DoubleToString(lots, 2));
   double room = VolumeRoom(_Symbol, true);
   if(room >= 0.0 && lots > room)
      PrintFormat("Only %.2f lots of buys fit under the symbol's volume limit", room);
  }

How it works

  • NormalizePrice(). Dividing by the tick size, rounding and multiplying puts the price on the grid of valid prices; NormalizeDouble() then removes the floating-point tail. On EUR/USD both steps give the same result. On a symbol quoted to 0.01 but traded in steps of 0.25, only the first gives a valid price.
  • NormalizeVolume() rounds down. A size computed from risk should never grow when it's rounded: 0.237 lots becomes 0.23 with a 0.01 step, not 0.24.
  • + 1e-9. 0.3 / 0.1 is 2.9999999999999996 in a double, and MathFloor() of that is 2: without the small nudge, 0.3 lots would become 0.2.
  • decimals. The number of decimals comes from the step (0.01 gives 2, 0.1 gives 1, 1 gives 0) instead of assuming 2, which is wrong for stocks and some CFDs.
  • VolumeRoom(). SYMBOL_VOLUME_LIMIT, when the broker sets one, caps the total volume of open positions and pending orders in one direction on the symbol. 0 means no limit.
  • The printed rules. Digits, point, tick size, tick value, stops level, freeze level and the volume limits are the numbers every other example on this site relies on. Run the script on each symbol you trade.

Pitfalls

  • NormalizeDouble(price, _Digits) on a coarse tick. The price has the right number of decimals but isn't a multiple of the tick, and the server refuses it with 10015 invalid price.
  • Rounding volumes to the nearest step. MathRound() rounds 0.235 up to 0.24 and risks more than you computed. Round down, and decide deliberately what to do when the result is below the minimum.
  • A volume off the step or outside the limits. Fails with 10014 invalid volume. Above the volume limit the retcode is 10034 (TRADE_RETCODE_LIMIT_VOLUME).
  • Stops computed from unrounded prices. A stop-loss that's right in points but off the tick grid, or one that ends up just inside the stops level after rounding, fails with 10016 invalid stops.
  • Points aren't pips. On five-digit forex quotes, one pip is ten points. A stop input of "30" means 3 pips if the code multiplies by _Point.

Variations

  • Lots from risk. The volume to normalize usually comes from a risk calculation: MQL5 lot size from risk percent computes it from the stop distance and the tick value, then rounds it the same way.
  • Pip size. For forex inputs in pips, double pip = (_Digits == 3 || _Digits == 5) ? 10 * _Point : _Point; is the common convention.
  • Comparing prices. Two computed prices are rarely exactly equal as doubles. Compare with a tolerance of half a tick: MathAbs(a - b) < tick / 2.
  • Check by hand. The free pip value calculator and position size calculator give the same numbers for common symbols, useful to sanity-check the code's output.

Questions

Why doesn't NormalizeDouble() fix my invalid price error?

NormalizeDouble() only rounds to a number of decimals. If the symbol's tick size is larger than its point, a price can have the right decimals and still not be a valid tick. Round to the tick size first: MathRound(price / tick) * tick.

What is the difference between _Point and SYMBOL_TRADE_TICK_SIZE?

_Point is the smallest change in the quote's last digit (0.00001 on a five-digit EUR/USD). The tick size is the smallest price change the symbol actually trades in. They're equal on most forex pairs and differ on some indices, futures and metals.

How do I round a lot size to two decimals in MQL5?

Don't assume two decimals: round down to SYMBOL_VOLUME_STEP, which can be 0.01, 0.1 or 1 depending on the symbol, then NormalizeDouble() with the number of decimals of that step.

What does SYMBOL_VOLUME_LIMIT mean?

It's the maximum total volume, open positions plus pending orders, allowed in one direction on the symbol. 0 means the broker sets no such limit. Orders beyond it are refused with retcode 10034.

This program is a minimal example written to show one technique, not a trading system, and none of it is a recommendation to trade. Trading involves substantial risk of loss and is not suitable for every investor. Edgoro is a software tool, not a broker or financial adviser: nothing it produces is investment advice, and past or backtested performance does not guarantee future results.

All MQL5 errors

Want it inside a complete EA?

Every EA Edgoro's MT5 EA generator writes rounds prices to the tick size and volumes to the broker's step and limits before it sends an order. A clean build proves the code compiles, not that a strategy works: test it on a demo account.