A Reusable Breakeven Manager in MQL5 with Spread Compensation
Introduction
You may have seen a familiar failure mode: an Expert Advisor moves a stop-loss "to breakeven" at the entry price and then, during a short spread spike (news or thin liquidity), the position is closed on that stop even though the market is barely retraced. The root causes are twofold and engineering-focused: (1) a breakeven check that ignores the fact that a trade is opened at ask but closed at bid—so the spread at modification time must be part of the breakeven level—and (2) a mistaken pip/point conversion on 3- and 5-digit symbols, which makes activation thresholds and buffers scale incorrectly. You need a small, testable module that (a) measures profit in real pips, (b) samples the live spread at the instant of modification, and (c) sets SL to open price ± current spread ± buffer (with pip size derived from the symbol's digits). This article implements exactly that: a CBreakevenManager with a clear API (Register(ticket, activation pips, buffer pips) and OnTick()), demo EA, and verification script.

Architecture of the breakeven manager. The Expert Advisor drives CBreakevenManager through Register() and OnTick(). On each tick, the manager reads the live spread, computes the breakeven level, and sends the modification, in that order, through OrderSend() with a TRADE_ACTION_SLTP request.
Section 1: The Breakeven Record
Every registered position needs a place to remember its own opening conditions, its own configuration, and whether it has already been moved. Scattering these across parallel arrays, one for tickets, one for open prices, one for buffer settings, would make it easy to get an index wrong and mismatch one position's ticket with another position's open price. A single struct, CBreakevenRecord, keeps every field that belongs to one position bundled together.
//+------------------------------------------------------------------+ //| BreakevenRecord.mqh | //+------------------------------------------------------------------+ #ifndef BREAKEVENRECORD_MQH #define BREAKEVENRECORD_MQH //+------------------------------------------------------------------+ //| CBreakevenRecord | //+------------------------------------------------------------------+ struct CBreakevenRecord { ulong ticket; string symbol; long position_type; double open_price; double point; int digits; double activation_threshold_pips; double buffer_pips; bool is_moved; double breakeven_level; CBreakevenRecord(void); };
The constructor starts every field at a neutral, unregistered value, so a freshly declared record is never mistaken for one that already holds real position data.
//+------------------------------------------------------------------+ //| CBreakevenRecord | //+------------------------------------------------------------------+ CBreakevenRecord::CBreakevenRecord(void) { //--- zeros out every field; is_moved starts false so OnTick() will //--- evaluate this record the first time it is encountered ticket = 0; symbol = ""; position_type = 0; open_price = 0.0; point = 0.0; digits = 0; activation_threshold_pips = 0.0; buffer_pips = 0.0; is_moved = false; breakeven_level = 0.0; }
Section 2: Sampling the Live Spread
The spread has to be read at the exact moment the stop loss is about to be modified, not stored once at registration and reused. A position can sit registered for hours before it reaches its activation threshold. If the spread were captured once when Register() was called, the value used in the breakeven formula could be completely disconnected from the actual market condition by the time the position is finally profitable enough to move. CSpreadSampler exists purely to make that live read happen consistently.
//+------------------------------------------------------------------+ //| SpreadSampler.mqh | //+------------------------------------------------------------------+ #ifndef SPREADSAMPLER_MQH #define SPREADSAMPLER_MQH //+------------------------------------------------------------------+ //| CSpreadSampler | //+------------------------------------------------------------------+ class CSpreadSampler { public: CSpreadSampler(void); ~CSpreadSampler(void); double GetCurrentSpread(const string symbol) const; double GetCurrentSpreadPips(const string symbol) const; }; //+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CSpreadSampler::CSpreadSampler(void) { } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CSpreadSampler::~CSpreadSampler(void) { }
GetCurrentSpread() reads SYMBOL_ASK and SYMBOL_BID for the given symbol and returns the spread in price terms: ask - bid. This is the raw value the breakeven formula needs, expressed in the same units as open_price.
//+------------------------------------------------------------------+ //| GetCurrentSpread | //+------------------------------------------------------------------+ double CSpreadSampler::GetCurrentSpread(const string symbol) const { //--- reads live ask and bid at this instant double ask = ::SymbolInfoDouble(symbol, SYMBOL_ASK); double bid = ::SymbolInfoDouble(symbol, SYMBOL_BID); //--- returns spread in price terms return(ask - bid); }
GetCurrentSpreadPips() exists as a convenience for logging and reporting, converting that same price-terms spread into a pip count. On 3-digit and 5-digit symbols, one pip equals ten points rather than one, so this method checks SYMBOL_DIGITS and divides by ten times the point size rather than the raw point on those symbols. Dividing by the raw point on a 5-digit symbol would report ten times too many pips.
//+------------------------------------------------------------------+ //| GetCurrentSpreadPips | //+------------------------------------------------------------------+ double CSpreadSampler::GetCurrentSpreadPips(const string symbol) const { //--- samples the live spread in price terms first double spread = GetCurrentSpread(symbol); //--- determines pip size: on 3- and 5-digit symbols one pip is ten points double point = ::SymbolInfoDouble(symbol, SYMBOL_POINT); int digits = (int)::SymbolInfoInteger(symbol, SYMBOL_DIGITS); double pip_size = (digits == 3 || digits == 5) ? point * 10.0 : point; if(pip_size <= 0.0) return(0.0); //--- converts the price-terms spread into a pip count return(spread / pip_size); }
Section 3: Computing the Breakeven Level
CBreakevenCalculator takes the pieces the manager gathers, the record and the live spread, and turns them into a single price: the breakeven level. It touches no live market data itself, which is what makes it possible to test with plain numbers instead of a real position.
//+------------------------------------------------------------------+ //| BreakevenCalculator.mqh | //+------------------------------------------------------------------+ #ifndef BREAKEVENCALCULATOR_MQH #define BREAKEVENCALCULATOR_MQH #include "BreakevenRecord.mqh" //+------------------------------------------------------------------+ //| CBreakevenCalculator | //+------------------------------------------------------------------+ class CBreakevenCalculator { public: CBreakevenCalculator(void); ~CBreakevenCalculator(void); double PipSize(const CBreakevenRecord &rec) const; double ComputeLevel(const CBreakevenRecord &rec, const double current_spread) const; bool IsActivated(const CBreakevenRecord &rec, const double current_close_price) const; }; //+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CBreakevenCalculator::CBreakevenCalculator(void) { } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CBreakevenCalculator::~CBreakevenCalculator(void) { }
PipSize() is a small helper both other methods rely on. It applies the same 3- and 5-digit convention already used by CSpreadSampler: one pip equals ten points on those symbols, one point everywhere else. It is public rather than private since CBreakevenManager needs the identical conversion when it logs a modification's buffer component, and duplicating the rule in two places would risk the two copies drifting apart.
//+------------------------------------------------------------------+ //| PipSize | //+------------------------------------------------------------------+ double CBreakevenCalculator::PipSize(const CBreakevenRecord &rec) const { if(rec.digits == 3 || rec.digits == 5) return(rec.point * 10.0); return(rec.point); }
ComputeLevel() implements the long formula open_price + spread + buffer_pips * pip_size, and its mirror for a short: open_price - spread - buffer_pips * pip_size. Adding the spread rather than ignoring it is the entire point of this article. It is what makes the resulting level genuinely break-even from the broker's perspective, since the position's true cost already includes the spread paid at entry.
//+------------------------------------------------------------------+ //| ComputeLevel | //+------------------------------------------------------------------+ double CBreakevenCalculator::ComputeLevel(const CBreakevenRecord &rec, const double current_spread) const { //--- buffer component in price terms, added on top of the spread double buffer_component = rec.buffer_pips * PipSize(rec); if(rec.position_type == POSITION_TYPE_BUY) { //--- long: breakeven level sits above open price by spread plus buffer return(rec.open_price + current_spread + buffer_component); } //--- short: breakeven level sits below open price by spread plus buffer return(rec.open_price - current_spread - buffer_component); }
IsActivated() answers a different question: has the position moved far enough in profit yet to justify moving the stop at all? For a long position, profit is measured as how far the current bid has risen above the open price, since bid is the price at which the position would actually close. For a short, profit is measured as how far the current ask has fallen below the open price. The parameter is named current_close_price rather than current_bid, since the caller passes bid for a long and ask for a short, and a name tied to one direction only would misdescribe the other.
//+------------------------------------------------------------------+ //| IsActivated | //+------------------------------------------------------------------+ bool CBreakevenCalculator::IsActivated(const CBreakevenRecord &rec, const double current_close_price) const { double activation_distance = rec.activation_threshold_pips * PipSize(rec); if(rec.position_type == POSITION_TYPE_BUY) { //--- long profit distance: how far the current bid has risen above open double profit_distance = current_close_price - rec.open_price; return(profit_distance >= activation_distance); } //--- short profit distance: how far the current ask has fallen below open double profit_distance = rec.open_price - current_close_price; return(profit_distance >= activation_distance); }
Section 4: Executing the Modification
CBreakevenExecutor is the only class in this article that touches the trade server. A stop loss modification in MQL5 has no global PositionModify() function. That name belongs to the CTrade class in the MQL5 standard library, and internally it does exactly what this class does directly: build an MqlTradeRequest with action set to TRADE_ACTION_SLTP, and send it through OrderSend(). Building the request directly here keeps this engine free of any dependency on the standard library.
//+------------------------------------------------------------------+ //| BreakevenExecutor.mqh | //+------------------------------------------------------------------+ #ifndef BREAKEVENEXECUTOR_MQH #define BREAKEVENEXECUTOR_MQH //+------------------------------------------------------------------+ //| CBreakevenExecutor | //+------------------------------------------------------------------+ class CBreakevenExecutor { public: CBreakevenExecutor(void); ~CBreakevenExecutor(void); bool Modify(const ulong ticket, const double new_sl, const double spread_component, const double buffer_component); }; //+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CBreakevenExecutor::CBreakevenExecutor(void) { } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CBreakevenExecutor::~CBreakevenExecutor(void) { }
Modify() needs four fields populated in the request: action set to TRADE_ACTION_SLTP, position set to the ticket, symbol set to the symbol, and sl set to the new breakeven level. The current take profit is read from the position and passed through unchanged, since omitting it would clear the take profit entirely rather than leaving it alone. Two extra parameters, spread_component and buffer_component, are never used inside the trade request itself. They exist purely so the log line can show exactly how much of the modification came from the live spread and how much came from the configured buffer. On failure, the log prints the attempted stop loss along with the retcode and comment the trade server returned, so a rejected modification is never silent; the manager leaves the position unmarked in that case and retries on the next tick.
//+--------------------------------------------------------------------+ //| Modify | //+--------------------------------------------------------------------+ bool CBreakevenExecutor::Modify(const ulong ticket, const double new_sl, const double spread_component, const double buffer_component) { if(!::PositionSelectByTicket(ticket)) { ::PrintFormat("CBreakevenExecutor: position %I64u no longer exists, skipping modification", ticket); return(false); } //--- reads the current SL and TP before changing anything, for logging //--- and so the take profit can be preserved in the request double old_sl = ::PositionGetDouble(POSITION_SL); double current_tp = ::PositionGetDouble(POSITION_TP); string symbol = ::PositionGetString(POSITION_SYMBOL); MqlTradeRequest request; MqlTradeResult result; ::ZeroMemory(request); ::ZeroMemory(result); //--- TRADE_ACTION_SLTP changes SL/TP only; volume and price are untouched request.action = TRADE_ACTION_SLTP; request.position = ticket; request.symbol = symbol; request.sl = new_sl; request.tp = current_tp; bool send_ok = ::OrderSend(request, result); if(send_ok && (result.retcode == TRADE_RETCODE_DONE || result.retcode == TRADE_RETCODE_PLACED)) { ::PrintFormat("CBreakevenExecutor: modified ticket=%I64u old_sl=%.5f new_sl=%.5f " "spread_component=%.5f buffer_component=%.5f", ticket, old_sl, new_sl, spread_component, buffer_component); return(true); } ::PrintFormat("CBreakevenExecutor: modification failed for ticket=%I64u old_sl=%.5f " "attempted_sl=%.5f retcode=%d comment=%s", ticket, old_sl, new_sl, result.retcode, result.comment); return(false); }
Section 5: The Breakeven Manager
CBreakevenManager owns one fixed-capacity array of CBreakevenRecord, plus one instance each of CSpreadSampler, CBreakevenCalculator, and CBreakevenExecutor. It is the only class the EA talks to directly, and it never calls OrderSend() itself; that stays inside CBreakevenExecutor.
//+------------------------------------------------------------------+ //| BreakevenManager.mqh | //+------------------------------------------------------------------+ #ifndef BREAKEVENMANAGER_MQH #define BREAKEVENMANAGER_MQH #include "BreakevenRecord.mqh" #include "SpreadSampler.mqh" #include "BreakevenCalculator.mqh" #include "BreakevenExecutor.mqh" #define BEM_MAX_POSITIONS 32 //+------------------------------------------------------------------+ //| CBreakevenManager | //+------------------------------------------------------------------+ class CBreakevenManager { private: CBreakevenRecord m_records[BEM_MAX_POSITIONS]; int m_count; CSpreadSampler m_sampler; CBreakevenCalculator m_calculator; CBreakevenExecutor m_executor; int FindIndex(const ulong ticket) const; public: CBreakevenManager(void); ~CBreakevenManager(void); bool Register(const ulong ticket, const double activation_pips, const double buffer_pips); void Deregister(const ulong ticket); void OnTick(void); bool GetRecord(const ulong ticket, CBreakevenRecord &rec) const; int Count(void) const { return(m_count); } }; //+------------------------------------------------------------------+ //| Constructor | //+------------------------------------------------------------------+ CBreakevenManager::CBreakevenManager(void) { m_count = 0; } //+------------------------------------------------------------------+ //| Destructor | //+------------------------------------------------------------------+ CBreakevenManager::~CBreakevenManager(void) { }
FindIndex() is a linear search returning the array index of the record matching a ticket, or -1 if it is not registered. It is private, used only internally by every other method that needs to locate a record.
//+------------------------------------------------------------------+ //| FindIndex | //+------------------------------------------------------------------+ int CBreakevenManager::FindIndex(const ulong ticket) const { for(int i = 0; i < m_count; i++) { if(m_records[i].ticket == ticket) return(i); } return(-1); }
Register() reads a position's live symbol, direction, open price, point size, and digit count exactly once and stores them in a new record. It refuses to register a ticket that is already registered, and it refuses if the registry is already full rather than silently overrunning the array.
//+------------------------------------------------------------------+ //| Register | //+------------------------------------------------------------------+ bool CBreakevenManager::Register(const ulong ticket, const double activation_pips, const double buffer_pips) { //--- registry is full, refuse rather than overrun the array if(m_count >= BEM_MAX_POSITIONS) { ::PrintFormat("CBreakevenManager: cannot register ticket=%I64u, registry full", ticket); return(false); } //--- do not register the same ticket twice if(FindIndex(ticket) >= 0) { ::PrintFormat("CBreakevenManager: ticket=%I64u is already registered", ticket); return(false); } //--- confirm the position still exists before reading its live state if(!::PositionSelectByTicket(ticket)) { ::PrintFormat("CBreakevenManager: cannot register ticket=%I64u, position not found", ticket); return(false); } int index = m_count; m_records[index].ticket = ticket; m_records[index].symbol = ::PositionGetString(POSITION_SYMBOL); m_records[index].position_type = ::PositionGetInteger(POSITION_TYPE); m_records[index].open_price = ::PositionGetDouble(POSITION_PRICE_OPEN); m_records[index].point = ::SymbolInfoDouble(m_records[index].symbol, SYMBOL_POINT); m_records[index].digits = (int)::SymbolInfoInteger(m_records[index].symbol, SYMBOL_DIGITS); m_records[index].activation_threshold_pips = activation_pips; m_records[index].buffer_pips = buffer_pips; m_records[index].is_moved = false; m_records[index].breakeven_level = 0.0; m_count++; ::PrintFormat("CBreakevenManager: registered ticket=%I64u symbol=%s type=%s open_price=%.5f " "activation_threshold=%.1f pips buffer=%.1f pips", ticket, m_records[index].symbol, (m_records[index].position_type == POSITION_TYPE_BUY ? "buy" : "sell"), m_records[index].open_price, activation_pips, buffer_pips); return(true); }
Deregister() removes a record from the registry using the same array-compaction pattern used throughout this article series: it overwrites the removed slot with the last entry and shrinks the count, avoiding an O(n) shift on every removal.
//+------------------------------------------------------------------+ //| Deregister | //+------------------------------------------------------------------+ void CBreakevenManager::Deregister(const ulong ticket) { int index = FindIndex(ticket); if(index < 0) return; //--- compact the array by overwriting the removed slot with the last entry m_count--; if(index < m_count) m_records[index] = m_records[m_count]; ::PrintFormat("CBreakevenManager: deregistered ticket=%I64u", ticket); }
OnTick() is where everything comes together. It walks every registered record, skips any whose position can no longer be selected (already closed, so it deregisters and moves on), and skips any already marked is_moved. For everything left, it reads the correct live price for that position's direction and asks the calculator whether the activation threshold has been reached. Only once it has, does it sample the live spread, compute the breakeven level, and hand both off to the executor. The logged buffer component uses the calculator's own PipSize(), so the number printed in the log always matches what ComputeLevel() actually used. The record is marked is_moved only after the executor confirms success.
//+------------------------------------------------------------------+ //| OnTick | //+------------------------------------------------------------------+ void CBreakevenManager::OnTick(void) { for(int i = m_count - 1; i >= 0; i--) { ulong ticket = m_records[i].ticket; //--- verify the position still exists; deregister if closed if(!::PositionSelectByTicket(ticket)) { Deregister(ticket); continue; } //--- is_moved gates every subsequent tick for this position if(m_records[i].is_moved) continue; string symbol = m_records[i].symbol; //--- long profit is measured against bid, short profit against ask double current_price = (m_records[i].position_type == POSITION_TYPE_BUY) ? ::SymbolInfoDouble(symbol, SYMBOL_BID) : ::SymbolInfoDouble(symbol, SYMBOL_ASK); if(!m_calculator.IsActivated(m_records[i], current_price)) continue; //--- spread is sampled now, at modification time, never at registration double current_spread = m_sampler.GetCurrentSpread(symbol); double buffer_component = m_records[i].buffer_pips * m_calculator.PipSize(m_records[i]); double new_sl = m_calculator.ComputeLevel(m_records[i], current_spread); bool modified = m_executor.Modify(ticket, new_sl, current_spread, buffer_component); if(!modified) { //--- leave is_moved false so the manager retries on the next tick continue; } m_records[i].is_moved = true; m_records[i].breakeven_level = new_sl; } }
GetRecord() is a simple lookup for status inspection, letting external code, or the test script, check a position's current breakeven state without needing to know the registry's internal array index.
//+------------------------------------------------------------------+ //| GetRecord | //+------------------------------------------------------------------+ bool CBreakevenManager::GetRecord(const ulong ticket, CBreakevenRecord &rec) const { int index = FindIndex(ticket); if(index < 0) return(false); rec = m_records[index]; return(true); }
Section 6 puts this to the test directly: a demo EA opens two identical positions side by side, one wired through CBreakevenManager, one left to a deliberately naive open-price-only stop, so the difference in survival during a spread spike is visible in the same log rather than described in the abstract.
Section 6: BreakevenEA.mq5 — Integration Demo
The demo EA opens two positions on OnInit() and manages them in two entirely different ways, so the difference in behavior can be observed side by side in the same log. The first, g_managed_ticket, is registered with CBreakevenManager. The second, g_naive_ticket, is never registered with the manager at all; it is checked separately by NaiveBreakevenCheck().
Before integrating this into your own EA, the checklist is short: open the position, call Register(ticket, activation_pips, buffer_pips) once, and call OnTick() on every tick. Nothing else is required for the manager to take over from there.
//+------------------------------------------------------------------+ //| BreakevenEA.mq5 | //+------------------------------------------------------------------+ #include <BreakevenManager/BreakevenManager.mqh> input double InpStopLossPoints = 300; // Stop loss distance in points, both demo positions input double InpLotSize = 0.10; // Entry lot size, both demo positions input double InpActivationPips = 10.0; // Activation threshold in pips input double InpBufferPips = 2.0; // Buffer pips for the spread-compensated position input ulong InpMagicNumber = 20260609; // Magic Number CBreakevenManager g_manager; ulong g_managed_ticket = 0; // spread-compensated position, handled by CBreakevenManager ulong g_naive_ticket = 0; // naive position, handled by this EA's own naive logic bool g_naive_moved = false;
OnInit() opens the managed position first, registers it with CBreakevenManager, then opens the naive position, which is deliberately left unregistered.
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit(void) { if(!OpenPosition(g_managed_ticket)) { ::Print("BreakevenEA: failed to open the managed demo position, EA will idle"); return(INIT_SUCCEEDED); } if(!g_manager.Register(g_managed_ticket, InpActivationPips, InpBufferPips)) { ::Print("BreakevenEA: failed to register the managed demo position"); } if(!OpenPosition(g_naive_ticket)) { ::Print("BreakevenEA: failed to open the naive demo position, EA will idle"); return(INIT_SUCCEEDED); } ::PrintFormat("BreakevenEA: managed ticket=%I64u (spread-compensated, buffer=%.1f pips), " "naive ticket=%I64u (open price only, no spread compensation)", g_managed_ticket, InpBufferPips, g_naive_ticket); return(INIT_SUCCEEDED); }
SelectFillingMode() picks a filling mode the symbol actually supports, avoiding a client-side rejection on brokers that do not advertise IOC.
//+------------------------------------------------------------------+ //| SelectFillingMode | //+------------------------------------------------------------------+ ENUM_ORDER_TYPE_FILLING SelectFillingMode(const string symbol) { long filling_flags = ::SymbolInfoInteger(symbol, SYMBOL_FILLING_MODE); if((filling_flags & SYMBOL_FILLING_FOK) != 0) return(ORDER_FILLING_FOK); if((filling_flags & SYMBOL_FILLING_IOC) != 0) return(ORDER_FILLING_IOC); return(ORDER_FILLING_RETURN); }
OpenPosition() sends a hardcoded long market order and returns its ticket through out_ticket.
//+------------------------------------------------------------------+ //| OpenPosition | //+------------------------------------------------------------------+ bool OpenPosition(ulong &out_ticket) { string symbol = ::Symbol(); double point = ::SymbolInfoDouble(symbol, SYMBOL_POINT); double ask = ::SymbolInfoDouble(symbol, SYMBOL_ASK); int digits = (int)::SymbolInfoInteger(symbol, SYMBOL_DIGITS); double sl = ::NormalizeDouble(ask - InpStopLossPoints * point, digits); MqlTradeRequest request; MqlTradeResult result; ::ZeroMemory(request); ::ZeroMemory(result); //--- opening market order carrying the hardcoded demo stop loss request.action = TRADE_ACTION_DEAL; request.symbol = symbol; request.volume = InpLotSize; request.type = ORDER_TYPE_BUY; request.price = ask; request.sl = sl; request.tp = 0.0; request.deviation = 10; request.magic = InpMagicNumber; request.type_filling = SelectFillingMode(symbol); if(!::OrderSend(request, result)) { ::PrintFormat("BreakevenEA: OrderSend failed, error=%d", ::GetLastError()); return(false); } if(result.retcode != TRADE_RETCODE_DONE && result.retcode != TRADE_RETCODE_PLACED) { ::PrintFormat("BreakevenEA: order rejected, retcode=%d comment=%s", result.retcode, result.comment); return(false); } out_ticket = result.order; //--- re-select to confirm the position exists before returning if(!::PositionSelectByTicket(out_ticket)) { ::PrintFormat("BreakevenEA: could not select new position, ticket=%I64u", out_ticket); return(false); } ::PrintFormat("BreakevenEA: opened ticket=%I64u volume=%.2f entry=%.5f sl=%.5f", out_ticket, InpLotSize, ask, sl); return(true); }
NaiveBreakevenCheck() simulates the flawed approach described in the introduction directly: once the activation threshold is reached, the SL is moved to the exact open price with no spread component and no buffer. It uses the same real-pip conversion as CBreakevenCalculator::PipSize() for its own activation check, so both arms of the demo activate at the same true profit distance and differ only in spread compensation, never in timing.
//+--------------------------------------------------------------------+ //| NaiveBreakevenCheck | //+--------------------------------------------------------------------+ void NaiveBreakevenCheck(void) { if(g_naive_ticket == 0 || g_naive_moved) return; if(!::PositionSelectByTicket(g_naive_ticket)) { g_naive_moved = true; return; } string symbol = ::PositionGetString(POSITION_SYMBOL); double open_price = ::PositionGetDouble(POSITION_PRICE_OPEN); double point = ::SymbolInfoDouble(symbol, SYMBOL_POINT); double bid = ::SymbolInfoDouble(symbol, SYMBOL_BID); //--- same real-pip convention as CBreakevenCalculator::PipSize(): on //--- 3- and 5-digit symbols one pip is ten points, not one int digits = (int)::SymbolInfoInteger(symbol, SYMBOL_DIGITS); double pip_size = (digits == 3 || digits == 5) ? point * 10.0 : point; //--- naive activation check: same real-pip threshold, but no spread //--- awareness follows once it fires double activation_distance = InpActivationPips * pip_size; if((bid - open_price) < activation_distance) return; MqlTradeRequest request; MqlTradeResult result; ::ZeroMemory(request); ::ZeroMemory(result); //--- the flaw: SL goes exactly to open_price, ignoring spread entirely request.action = TRADE_ACTION_SLTP; request.position = g_naive_ticket; request.symbol = symbol; request.sl = open_price; request.tp = ::PositionGetDouble(POSITION_TP); bool send_ok = ::OrderSend(request, result); if(send_ok && (result.retcode == TRADE_RETCODE_DONE || result.retcode == TRADE_RETCODE_PLACED)) { ::PrintFormat("BreakevenEA (naive): ticket=%I64u SL moved to open_price=%.5f, " "no spread compensation applied", g_naive_ticket, open_price); g_naive_moved = true; } }
OnTick() and OnDeinit() are both short by design, since all real decision-making lives inside the manager.
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick(void) { g_manager.OnTick(); NaiveBreakevenCheck(); } //+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { if(g_managed_ticket != 0) g_manager.Deregister(g_managed_ticket); }

Breakeven level drawn above the entry price, labeled with the buffer and spread that produced it.
Section 7: Verification, TestBreakevenManager.mq5
MQL5 has no native assert, so the script defines a small BE_ASSERT macro backed by a TestAssert() function that logs PASS or FAIL and keeps a running count.
//+------------------------------------------------------------------+ //| TestBreakevenManager.mq5 | //+------------------------------------------------------------------+ #property script_show_inputs #include <BreakevenManager/BreakevenRecord.mqh> #include <BreakevenManager/BreakevenCalculator.mqh> #include <BreakevenManager/SpreadSampler.mqh> int g_pass_count = 0; int g_fail_count = 0; //+------------------------------------------------------------------+ //| ASSERT macro replacement, since MQL5 has no native assert. | //+------------------------------------------------------------------+ #define BE_ASSERT(condition, message) TestAssert((condition), (message)) //+------------------------------------------------------------------+ //| TestAssert | //| Logs PASS or FAIL for one condition and keeps a running count. | //+------------------------------------------------------------------+ void TestAssert(const bool condition, const string message) { if(condition) { g_pass_count++; ::PrintFormat("PASS: %s", message); } else { g_fail_count++; ::PrintFormat("FAIL: %s", message); } } //+------------------------------------------------------------------+ //| Script program start function | //+------------------------------------------------------------------+ void OnStart(void) { ::Print("=== TestBreakevenManager starting ==="); TestSpreadPipsConversion(); TestLongBreakevenFormula(); TestShortBreakevenFormula(); TestActivationThreshold(); TestIsMovedBlocksModification(); ::PrintFormat("=== TestBreakevenManager finished: %d passed, %d failed ===", g_pass_count, g_fail_count); }
TestSpreadPipsConversion() confirms the pip conversion formula directly, checking that a spread of 0.00015 with a 5-digit point of 0.00001 converts to 1.5 pips using the ten-points-per-pip convention.
//+------------------------------------------------------------------+ //| TestSpreadPipsConversion | //+------------------------------------------------------------------+ void TestSpreadPipsConversion(void) { ::Print("--- TestSpreadPipsConversion ---"); double point = 0.00001; double pip_size = point * 10.0; double spread = 0.00015; double spread_pips = spread / pip_size; BE_ASSERT(::MathAbs(spread_pips - 1.5) < 0.0001, "Spread of 0.00015 with point 0.00001 converts to 1.5 pips"); }
TestLongBreakevenFormula() constructs a CBreakevenCalculator and a CBreakevenRecord directly, setting digits = 5 so the real-pip conversion applies, confirming the long formula produces 1.10035 for an open price of 1.10000, a spread of 0.00015, and a 2-pip buffer.
//+------------------------------------------------------------------+ //| TestLongBreakevenFormula | //+------------------------------------------------------------------+ void TestLongBreakevenFormula(void) { ::Print("--- TestLongBreakevenFormula ---"); CBreakevenCalculator calc; CBreakevenRecord rec; rec.position_type = POSITION_TYPE_BUY; rec.open_price = 1.10000; rec.point = 0.00001; rec.digits = 5; rec.buffer_pips = 2.0; double spread = 0.00015; double level = calc.ComputeLevel(rec, spread); BE_ASSERT(::MathAbs(level - 1.10035) < 0.000001, "Long breakeven level for open=1.10000, spread=0.00015, buffer=2 pips is 1.10035"); }
TestShortBreakevenFormula() is the mirror case, confirming the short formula produces 1.09965 for the same inputs.
//+------------------------------------------------------------------+ //| TestShortBreakevenFormula | //+------------------------------------------------------------------+ void TestShortBreakevenFormula(void) { ::Print("--- TestShortBreakevenFormula ---"); CBreakevenCalculator calc; CBreakevenRecord rec; rec.position_type = POSITION_TYPE_SELL; rec.open_price = 1.10000; rec.point = 0.00001; rec.digits = 5; rec.buffer_pips = 2.0; double spread = 0.00015; double level = calc.ComputeLevel(rec, spread); BE_ASSERT(::MathAbs(level - 1.09965) < 0.000001, "Short breakeven level for open=1.10000, spread=0.00015, buffer=2 pips is 1.09965"); }
TestActivationThreshold() confirms a bid 12 real pips in profit correctly activates a 10-pip threshold on a 5-digit symbol, while a bid 8 real pips in profit does not.
//+------------------------------------------------------------------+ //| TestActivationThreshold | //+------------------------------------------------------------------+ void TestActivationThreshold(void) { ::Print("--- TestActivationThreshold ---"); CBreakevenCalculator calc; CBreakevenRecord rec; rec.position_type = POSITION_TYPE_BUY; rec.open_price = 1.10000; rec.point = 0.00001; rec.digits = 5; rec.activation_threshold_pips = 10.0; bool activated_case = calc.IsActivated(rec, 1.10120); bool not_activated_case = calc.IsActivated(rec, 1.10080); BE_ASSERT(activated_case == true, "Bid 1.10120 (12 real pips profit) correctly activates a 10-pip threshold"); BE_ASSERT(not_activated_case == false, "Bid 1.10080 (8 real pips profit) correctly does NOT activate a 10-pip threshold"); }
TestIsMovedBlocksModification() confirms the flag itself behaves as a simple, reliable boolean gate, since testing the full tick loop directly would require a live open position the script does not have.
//+-------------------------------------------------------------------+ //| TestIsMovedBlocksModification | //+-------------------------------------------------------------------+ void TestIsMovedBlocksModification(void) { ::Print("--- TestIsMovedBlocksModification ---"); CBreakevenRecord rec; BE_ASSERT(rec.is_moved == false, "A newly constructed CBreakevenRecord starts with is_moved false"); rec.is_moved = true; BE_ASSERT(rec.is_moved == true, "is_moved correctly reports true once set, gating OnTick() from re-evaluating it"); }
Each check matters independently. A broken pip conversion misreports the spread or the buffer in every log line. A broken formula places the stop at the wrong price entirely. A broken activation check moves the stop too early or too late. A broken is_moved flag means a position's stop could be recalculated repeatedly, defeating the entire purpose of the flag.
Section 8: Extending the Manager
A minimum time-in-trade requirement before activation would need CBreakevenRecord to also store the position's open time, checked against TimeCurrent() inside CBreakevenCalculator::IsActivated() alongside the existing pip-based check.
A second breakeven move at a higher profit threshold, adding a larger buffer, would need a second activation threshold and buffer value stored per position, along with a second flag similar to is_moved tracking whether that second move has already happened.
Logging modification history per ticket to a CSV file for post-trade analysis fits naturally as an addition to CBreakevenExecutor::Modify(), appending a line to a file opened with FileOpen() in append mode, right alongside the existing PrintFormat() call.
Integrating with a companion trailing engine so that trailing only begins after breakeven is confirmed would use CBreakevenManager::GetRecord() as the gate: a trailing engine's own tick handler would check rec.is_moved before evaluating any trailing method for that position.
Section 9: Limitations
Limitations that affect breakeven correctness directly:
- The is_moved flag prevents any subsequent adjustment once the level is set. If the spread widens permanently after modification, the level that was correct at that moment is never revisited.
- A failed modification leaves is_moved false and retries on the next tick, but with no bounded retry count or backoff.
Limitations that are scope choices, not correctness gaps:
- The manager does not account for overnight swap costs.
- The activation threshold is a fixed pip count and does not adapt to volatility.
Conclusion
The repository produced in this article delivers a practical, modular solution to the breakeven problem. CBreakevenManager and its supporting components (a position record, live spread sampler, pip-aware calculator, SL modification executor, demo EA, and a test script) guarantee the following properties: when a configured profit in real pips is reached, the manager samples the spread at modification time and issues a single SL update using the formula
- for BUY: new SL = open price + current spread + buffer pips * pip_size
- for SELL: new SL = open price - current spread - buffer pips * pip_size. The implementation converts pips correctly on 3- and 5-digit symbols, logs the split between spread and buffer, and exposes a minimal integration surface—call Register() once per position and call OnTick() each tick. A verification script exercises pip conversion, the long/short formulas, activation logic, and the is_moved gating, providing reproducible proof of behavior.
Limitations remain explicit: the manager does not account for overnight swap costs, uses a fixed (non-adaptive) activation threshold, has no bounded retry/backoff on failed modifications, and marks a position final after one successful move (no automatic re-adjustments). These are deliberate design choices to keep the module simple and auditable; the article also outlines straightforward extensions (time-in-trade checks, multiple staged breakeven levels, logging to CSV, or integration with a trailing engine) for readers who need them.
Programs used in the article:
| # | Name | Type | Description |
|---|---|---|---|
| 1 | BreakevenRecord.mqh | Include File | CBreakevenRecord struct holding all engine-internal state for one registered position |
| 2 | SpreadSampler.mqh | Include File | CSpreadSampler class sampling live spread in price terms and in pips |
| 3 | BreakevenCalculator.mqh | Include File | CBreakevenCalculator class computing the breakeven level and the activation threshold |
| 4 | BreakevenExecutor.mqh | Include File | CBreakevenExecutor class executing the modification and logging its full detail |
| 5 | BreakevenManager.mqh | Include File | CBreakevenManager class, the public interface owning all sub-components |
| 6 | BreakevenEA.mq5 | Demo EA | Demo EA comparing a spread-compensated position against a naive one |
| 7 | TestBreakevenManager.mq5 | Script | Verification script covering the spread, formula, activation, and gating logic |
| 8 | BreakevenManager.zip | Zip Archive | Zip archive containing all the attached files and their paths relative to the terminal's root folder. |
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.
Network Momentum for MetaTrader5: Trading the Lead-Lag Graph Between Markets
Quantum Computing and Gradient Boosting in EURUSD Trading
Neural Networks in Trading: An End-to-End Multivariate Time Series Forecasting Model (GinAR)
Motifs and Discords: Building a Matrix Profile from Scratch
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use