DEV Community

Omais Ali
Omais Ali

Posted on

Moving a stop to breakeven in MQL5 without rejected modifies or hidden losses

Moving the stop loss to breakeven once a trade is in profit is one of the most requested Expert Advisor features. It also produces a surprising number of bug reports: the stop never moves, the journal fills with errors, or the trade closes "at breakeven" and still shows a small loss.

This tutorial builds a breakeven routine that measures profit from the right price, respects the broker's distance rules, only ever tightens the stop, and covers trading costs.

Measure profit from the price you would close at

A buy position closes at the bid. A sell position closes at the ask. If you measure a buy against the ask, or a sell against the bid, the spread gets counted as profit and the stop moves too early. On a wide spread symbol that can mean the stop is moved before the trade is really in profit at all.

Breakeven is not the entry price

If the stop sits exactly at the open price, a trade that comes back and hits it still loses the commission and any swap it has collected. So "breakeven" should usually be the entry plus a small offset in your favour, large enough to cover commission and a little slippage. For a buy that is above the entry, for a sell below it.

On netting accounts there is one more catch. If the EA adds to a position, POSITION_PRICE_OPEN becomes the volume weighted average price of the whole position, so the breakeven level moves with every addition. That is usually what you want, but test it on purpose.

Helpers

The two helpers convert points to a price distance and round a price to the symbol's tick size. Rounding to the tick size rather than to the number of digits matters on indices and metals where the tick is larger than one point.

#include <Trade\Trade.mqh>
CTrade trade;

input int   InpTriggerPoints = 200;   // profit in points before the stop moves
input int   InpOffsetPoints  = 30;    // points beyond entry to lock in, covers costs
input ulong InpMagic         = 20261025;

double PointsToPrice(const string sym, const int pts)
{
   double point = SymbolInfoDouble(sym, SYMBOL_POINT);
   return pts / (1.0 / point);        // pts times point
}

double RoundToTick(const string sym, const double price)
{
   double tick   = SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_SIZE);
   int    digits = (int)SymbolInfoInteger(sym, SYMBOL_DIGITS);
   if(tick <= 0.0)
      return NormalizeDouble(price, digits);
   return NormalizeDouble(MathRound(price / tick) / (1.0 / tick), digits);
}
Enter fullscreen mode Exit fullscreen mode

Respect the stops level and the freeze level

Two broker settings decide whether a modify request is even allowed.

  1. SYMBOL_TRADE_STOPS_LEVEL is the minimum distance in points between the current price and a new stop. Place the stop closer and the request is rejected with invalid stops.
  2. SYMBOL_TRADE_FREEZE_LEVEL forbids modifying a position whose existing stop loss or take profit is already within that distance of the current price. The position is frozen until price moves away.

Checking both before sending saves a rejected request and a noisy log.

bool ModifyAllowed(const string sym, const long type, const double newSl,
                   const double curSl, const double curTp, const MqlTick &tick)
{
   double stopsDist  = PointsToPrice(sym, (int)SymbolInfoInteger(sym, SYMBOL_TRADE_STOPS_LEVEL));
   double freezeDist = PointsToPrice(sym, (int)SymbolInfoInteger(sym, SYMBOL_TRADE_FREEZE_LEVEL));
   double price      = (type == POSITION_TYPE_BUY) ? tick.bid : tick.ask;

   // the new stop must be far enough from the closing price
   if(MathAbs(price - newSl) <= stopsDist)
      return false;

   // existing levels inside the freeze distance block any modify
   if(freezeDist > 0.0)
   {
      if(curSl > 0.0 && MathAbs(price - curSl) <= freezeDist) return false;
      if(curTp > 0.0 && MathAbs(price - curTp) <= freezeDist) return false;
   }
   return true;
}
Enter fullscreen mode Exit fullscreen mode

The breakeven routine

The loop walks backwards through positions, skips anything that does not carry the EA's magic number, and only sends a modify when the stop would actually improve. That last check is what stops the journal from filling with TRADE_RETCODE_NO_CHANGES on every tick.

void ManageBreakeven()
{
   for(int i = PositionsTotal() - 1; i >= 0; i--)
   {
      ulong ticket = PositionGetTicket(i);
      if(ticket == 0)
         continue;
      if(PositionGetInteger(POSITION_MAGIC) != (long)InpMagic)
         continue;

      string sym  = PositionGetString(POSITION_SYMBOL);
      long   type = PositionGetInteger(POSITION_TYPE);
      double open = PositionGetDouble(POSITION_PRICE_OPEN);
      double sl   = PositionGetDouble(POSITION_SL);
      double tp   = PositionGetDouble(POSITION_TP);

      MqlTick tick;
      if(!SymbolInfoTick(sym, tick))
         continue;

      double trigger = PointsToPrice(sym, InpTriggerPoints);
      double offset  = PointsToPrice(sym, InpOffsetPoints);
      double target  = 0.0;

      if(type == POSITION_TYPE_BUY)
      {
         if(tick.bid - open < trigger)
            continue;                              // not far enough in profit
         target = RoundToTick(sym, open + offset);
         if(sl > 0.0 && sl >= target)
            continue;                              // already at or past breakeven
      }
      else
      {
         if(open - tick.ask < trigger)
            continue;
         target = RoundToTick(sym, open - offset);
         if(sl > 0.0 && sl <= target)
            continue;
      }

      if(!ModifyAllowed(sym, type, target, sl, tp, tick))
         continue;                                 // try again on a later tick

      if(!trade.PositionModify(ticket, target, tp))
         PrintFormat("Breakeven modify failed on %I64u: %u %s",
                     ticket, trade.ResultRetcode(), trade.ResultRetcodeDescription());
      else
         PrintFormat("Breakeven set on %I64u at %s", ticket, DoubleToString(target, _Digits));
   }
}

void OnTick()
{
   ManageBreakeven();
}
Enter fullscreen mode Exit fullscreen mode

A few details worth calling out.

  1. The take profit is passed back unchanged. PositionModify sets both levels, so sending 0 for the take profit would silently remove it.
  2. The comparison against the existing stop works in one direction only. The stop can move toward profit, never back toward risk.
  3. If the trigger is smaller than the offset plus the stops level, the routine will keep finding the move not allowed. Keep the trigger comfortably larger than the offset.
  4. PrintFormat uses _Digits for display only. If the EA trades several symbols, read SYMBOL_DIGITS for each one.

Choosing the offset

A reasonable starting point is the commission for a round trip expressed in points, plus a few points for slippage. On a raw spread account charging a fixed commission per lot, work out how many points of price movement that commission equals for your typical lot size, and use that as the floor. On a commission free account the offset can be small, but rarely zero.

Testing it

  1. Run the Strategy Tester in visual mode on a volatile day and watch the stop jump exactly once per position.
  2. Check the journal for repeated modify errors. One or two invalid stops messages near news are normal; hundreds mean a check is missing.
  3. Test a sell as carefully as a buy. Most breakeven bugs live in the side the developer tested less.
  4. Try a symbol with a nonzero freeze level, or temporarily force one in code, to make sure frozen positions are skipped instead of retried in a tight loop.

Breakeven sounds like one line of logic. Done properly it is a small set of rules about prices, distances and costs, and getting them right is what makes the feature trustworthy.

Top comments (2)

Collapse
 
stratcorealpha profile image
Arnold Holm •

One check is missing before printing “Breakeven set”: PositionModify() returning true does not confirm server execution. Check ResultRetcode() before reporting success, then reselect the ticket and verify the actual SL. A useful negative case is a server-side rejection after the local distance check: the log must show the rejection, not “Breakeven set”. The MQL5 reference explicitly distinguishes the boolean return from the server result.

Collapse
 
omaisali profile image
Omais Ali •

You are right, and that is a real gap in the example. With CTrade, PositionModify() returning true only means the request went out. The success message should depend on ResultRetcode() being TRADE_RETCODE_DONE, and even then I would reselect the ticket with PositionSelectByTicket() and compare POSITION_SL with the intended level within half a point before logging success. On any other retcode the log should print the code and ResultRetcodeDescription(), and the breakeven flag for that ticket should stay unset so the next tick can try again within its limits.

For the negative case, a server rejection is hard to trigger on demand in the tester, so I route the modify through a small wrapper that a test build can force to return TRADE_RETCODE_INVALID_STOPS. The test then asserts that the log shows the rejection, the flag is still false and the stop on the position is unchanged.