Edgoro
Expert Advisor · MQL5

Move stop-loss to break-even in MQL5 (costs included)

Short answer

Once a position's profit reaches your trigger, call trade.PositionModify(ticket, newSl, tp) with the stop at the entry price plus a small offset (minus for a sell). Send it only when the current stop is worse than that level and the new one respects SYMBOL_TRADE_STOPS_LEVEL; otherwise the server answers 10025 (no changes) or 10016 (invalid stops).

UsesCTrade::PositionModifyPositionGetDoubleHistorySelectByPositionOrderCalcProfitSYMBOL_TRADE_FREEZE_LEVEL

The code

An Expert Advisor that manages positions already open on its chart's symbol, including manual ones when the magic number is -1. It modifies real stops, so try it on a demo account first.

Save it as MQL5/Experts/move-stop-loss-to-break-even.mq5 and compile it in MetaEditor (F7): 0 errors, 0 warnings.

MQL5
#include <Trade/Trade.mqh>

input int  InpTriggerPoints = 300;  // Profit, in points, that moves the stop to break-even
input int  InpLockPoints    = 20;   // Extra points locked in beyond break-even
input bool InpCoverCosts    = true; // Break-even after commission and swap, not just the entry price
input long InpMagic         = -1;   // Magic number to manage (-1 = every position on this symbol)

CTrade trade;

double RoundToTick(const double price)
  {
   double tick = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE);
   if(tick <= 0.0)
      return NormalizeDouble(price, _Digits);
   return NormalizeDouble(MathRound(price / tick) * tick, _Digits);
  }

// Price distance that pays back the commission and swap of a position. Commission is often charged again on
// the closing deal, so the entry commission is counted twice.
double CostDistance(const long positionId, const bool isBuy, const double volume, const double open, const double swap)
  {
   double commission = 0.0;
   if(HistorySelectByPosition(positionId))
      for(int i = HistoryDealsTotal() - 1; i >= 0; i--)
        {
         ulong deal = HistoryDealGetTicket(i);
         if(deal > 0)
            commission += HistoryDealGetDouble(deal, DEAL_COMMISSION);
        }
   double cost = -(2.0 * commission + swap);   // commission is negative, swap can be either
   if(cost <= 0.0)
      return 0.0;
   double tick = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE);
   double perTick = 0.0;                       // money made by this volume over one tick
   if(tick <= 0.0 || !OrderCalcProfit(isBuy ? ORDER_TYPE_BUY : ORDER_TYPE_SELL, _Symbol, volume, open,
                                      isBuy ? open + tick : open - tick, perTick) || perTick <= 0.0)
      return 0.0;
   return MathCeil(cost / perTick) * tick;
  }

void ManageBreakEven()
  {
   double stops  = (double)SymbolInfoInteger(_Symbol, SYMBOL_TRADE_STOPS_LEVEL) * _Point;
   double freeze = (double)SymbolInfoInteger(_Symbol, SYMBOL_TRADE_FREEZE_LEVEL) * _Point;
   MqlTick tick;
   if(!SymbolInfoTick(_Symbol, tick))
      return;

   for(int i = PositionsTotal() - 1; i >= 0; i--)
     {
      ulong ticket = PositionGetTicket(i);
      if(ticket == 0 || PositionGetString(POSITION_SYMBOL) != _Symbol)
         continue;
      if(InpMagic >= 0 && PositionGetInteger(POSITION_MAGIC) != InpMagic)
         continue;

      bool   isBuy = PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY;
      double open  = PositionGetDouble(POSITION_PRICE_OPEN);
      double sl    = PositionGetDouble(POSITION_SL);
      double tp    = PositionGetDouble(POSITION_TP);
      double price = isBuy ? tick.bid : tick.ask;   // the price the position would close at
      if((isBuy ? price - open : open - price) < InpTriggerPoints * _Point)
         continue;                                  // not far enough in profit yet

      double offset = InpLockPoints * _Point;
      if(InpCoverCosts)
         offset += CostDistance(PositionGetInteger(POSITION_IDENTIFIER), isBuy, PositionGetDouble(POSITION_VOLUME),
                                open, PositionGetDouble(POSITION_SWAP));
      double be = RoundToTick(isBuy ? open + offset : open - offset);

      if(sl != 0.0 && (isBuy ? sl >= be : sl <= be))
         continue;                                  // already at or beyond break-even: nothing to send
      double room = MathMax(stops, _Point);         // the stop must stay at least the stops level from the price
      if(isBuy ? price - be < room : be - price < room)
         continue;                                  // too close for this broker: wait for more profit
      if(freeze > 0.0 && ((sl != 0.0 && MathAbs(price - sl) < freeze) || (tp != 0.0 && MathAbs(price - tp) < freeze)))
         continue;                                  // inside the freeze level: the server refuses any change

      if(!trade.PositionModify(ticket, be, tp) || trade.ResultRetcode() != TRADE_RETCODE_DONE)
         PrintFormat("Break-even on #%I64u failed: retcode %u (%s)", ticket, trade.ResultRetcode(), trade.ResultRetcodeDescription());
      else
         PrintFormat("#%I64u: stop moved to %s (entry %s)", ticket, DoubleToString(be, _Digits), DoubleToString(open, _Digits));
     }
  }

void OnTick()
  {
   ManageBreakEven();
  }

How it works

  • Profit from the closing price. A buy's profit is measured from the Bid and a sell's from the Ask, the prices they would close at. The trigger is in points, so 300 means 30 pips on a five-digit EUR/USD quote.
  • CostDistance(). It adds up the commission of the position's deals with HistorySelectByPosition(), counts it twice because many brokers charge it again on the closing deal, adds the swap, and turns that amount into a price distance with OrderCalcProfit() over one tick. That works for any symbol and account currency, without hand-coded pip values.
  • sl >= be (buy) or sl <= be (sell). If the stop is already at or beyond break-even, nothing is sent. That makes the move happen once, from state the server keeps, so a restart can't send it twice.
  • room. The new stop must stay at least the stops level away from the price. If the trigger is reached but the stop would be too close, the EA waits for more profit instead of sending a request that would be refused.
  • The freeze check. Within SYMBOL_TRADE_FREEZE_LEVEL of the current stop or target, the server refuses any change to the position.
  • PositionModify(ticket, be, tp). The take-profit is passed back unchanged; passing 0 would remove it.

Pitfalls

  • Break-even at the entry price still loses money. On an account charging 3.50 per lot per side, a one-lot position stopped exactly at its entry loses 7 in commission, plus any swap. That's why the EA adds the costs; set InpCoverCosts to false if you want the plain entry price.
  • Sending the same stop on every tick. Without the "already there" check, every tick after the trigger sends an identical stop and gets 10025 no changes.
  • A trigger smaller than the stops level plus the offset. The new stop then lands inside the broker's minimum distance and is refused with 10016 invalid stops. Keep the trigger well above SYMBOL_TRADE_STOPS_LEVEL plus InpLockPoints.
  • Moving the stop inside the freeze level. Near the current stop or target, the change fails with 10029 frozen.
  • Netting accounts. Adding to a position moves POSITION_PRICE_OPEN to the volume-weighted average, so the break-even level moves too, and the stop applies to the whole netted position.

Variations

  • Then trail. Break-even is often the first step of a trailing stop. MQL5 trailing stop continues from here with fixed-distance and ATR trailing.
  • After a partial close. Close half at the first target, then move the rest to break-even: partial close does both.
  • Trigger at 1R. Instead of fixed points, trigger when profit equals the initial risk. The original stop is on the position's entry deal: read HistoryDealGetDouble(deal, DEAL_SL) for the DEAL_ENTRY_IN deal.
  • Only this EA's trades. Set InpMagic to the magic number of the EA that opens the positions, so the two programs can run side by side.

Questions

Should a break-even stop include the spread?

Not for a zero gross result. A buy is entered at the Ask and its stop triggers at the Bid, so a stop at the entry price closes when the Bid is back at the price you paid: the spread was already paid on entry. Commission and swap are separate costs, which the EA adds.

How do I move the stop to break-even only once?

Compare the position's current stop with the break-even level and send nothing when it's already there or beyond. That state is kept by the server, so it survives restarts, unlike a boolean flag in the EA's memory.

Can an EA move manual trades to break-even?

Yes. Manual trades have the magic number 0: run the EA with InpMagic set to 0 for manual trades only, or -1 for every position on the chart's symbol.

Can a trade whose stop is at break-even still lose money?

Yes. A stop-loss becomes a market order when it triggers, so in a fast market or over a weekend gap it can fill below a buy's stop (above a sell's). The stop limits the loss in normal conditions; it doesn't cap it.

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?

Describe your exit rules to Edgoro's MT5 EA generator, break-even, partial closes and trailing included, and get a complete EA that checks the broker's distances before every change. A clean build proves the code compiles, not that a strategy works: test it on a demo account.