CTrade::PositionClose() — The Return Value Contract and What It Costs in Production

CTrade::PositionClose() — The Return Value Contract and What It Costs in Production

3 June 2026, 10:07
Boris Armenteros
0
47
If you are migrating from MQL4 or debugging a position management bug you cannot reproduce in Strategy Tester, read this before changing anything else.

What the Documentation Actually Says

The MQL5 reference for `CTrade::PositionClose()` is technically accurate. Both overloads are listed. The return value is documented as a boolean. What the documentation does not state plainly is what `true` actually means in terms of account state: the request passed internal validation and was sent to the broker. Nothing more.

This is distinct from the old MQL4 contract. `OrderClose()` in MQL4 was synchronous — when it returned `true`, the order was settled and the next line of code ran against a consistent account state. Developers who built systems on that contract and are now porting to MT5 carry the assumption forward, often without realizing it.

The Async Gap in Live Conditions

When `PositionClose()` returns `TRADE_RETCODE_DONE`, the close request has been acknowledged. The broker confirmation is pending. On ECN brokers under normal load, the time between `TRADE_RETCODE_DONE` and the position actually disappearing from `PositionSelect()` is approximately 300–800ms.

Strategy Tester does not replicate this gap. Every backtest will pass cleanly. The failure surfaces at market open on the first live day.

The practical consequence: if the next line of code after `PositionClose()` calls `PositionSelect()` to check account state before running entry logic, it will find the old position still present. If entry logic is condition-gated on "no open position," it will either skip the entry or open a second position alongside the one being closed. The account briefly holds double exposure with no error in the log.

Here is the naive pattern that produces this:

bool closed = m_trade.PositionClose(Symbol());
if(closed)
{
    // WRONG: position may still be present at this point
    if(!PositionSelect(Symbol()))
    {
        // Opens entry — but if old position is still settling, 
        // PositionSelect returns true, block is skipped silently
    }
}

The Deferred Verification Pattern

The correct pattern defers any logic that depends on a closed account state until position removal is confirmed on the next tick:

bool m_closePending = false;

bool ClosePosition(const string symbol)
{
    if(!m_trade.PositionClose(symbol))
    {
        printf("%s: PositionClose failed | Retcode=%d | Symbol=%s",
               __FUNCTION__, m_trade.ResultRetcode(), symbol);
        return false;
    }
    m_closePending = true;
    return true;
}

void OnTick()
{
    if(m_closePending)
    {
        if(!PositionSelect(_Symbol))
        {
            m_closePending = false;
            // Position confirmed gone — safe to run dependent logic
        }
        return;    // Block entry logic until confirmed
    }

    // ... entry logic here
}
The flag blocks all dependent logic until `PositionSelect()` returns `false`, confirming actual removal. No sleep, no arbitrary delays — just a state check on the next tick.

OnTradeTransaction Timing

`OnTradeTransaction` fires as part of the confirmation sequence, but it fires before the position registry has fully updated. Reading `PositionSelect()` inside `OnTradeTransaction` will return stale state in the gap period. Use `OnTradeTransaction` to set flag state only — never to read position state.

void OnTradeTransaction(const MqlTradeTransaction &trans,
                        const MqlTradeRequest &request,
                        const MqlTradeResult &result)
{
    if(trans.type == TRADE_TRANSACTION_DEAL_ADD)
    {
        // Set flags here — do NOT call PositionSelect()
        m_transactionReceived = true;
    }
}


Handling Four Return Codes Correctly

Not all retcodes from `PositionClose()` are equivalent:
  • `TRADE_RETCODE_DONE` — Request accepted. Apply deferred verification.
  • `TRADE_RETCODE_REQUOTE` — Price changed during request. Retry immediately using the current bid or ask.
  • `TRADE_RETCODE_REJECTED` — Request was invalid. Check symbol, margin requirements, and stop levels before retrying.
  • `TRADE_RETCODE_TIMEOUT` — No confirmation received. This is the dangerous case: the order may have filled and the confirmation was lost in transit. Always verify position state independently before retrying. A blind retry on a `TIMEOUT` that filled will open a second position.
uint retcode = m_trade.ResultRetcode();

if(retcode == TRADE_RETCODE_DONE)
{
    m_closePending = true;
}
else if(retcode == TRADE_RETCODE_REQUOTE)
{
    // Retry at current price on next tick
    m_retryClose = true;
}
else if(retcode == TRADE_RETCODE_REJECTED)
{
    printf("%s: Close rejected | Check symbol/margin/stops | Symbol=%s",
           __FUNCTION__, _Symbol);
}
else if(retcode == TRADE_RETCODE_TIMEOUT)
{
    // Verify state before any retry
    if(!PositionSelect(_Symbol))
    {
        m_closePending = false;  // Filled despite timeout
    }
    else
    {
        m_retryClose = true;  // Genuinely unprocessed — retry safe
    }
}

What This Looks Like in a Production EA

The full pattern combines all of the above: `ClosePosition()` sets the pending flag, `OnTick()` checks and clears it after confirming removal, and retcode handling branches to the correct response for each case.

This pattern is not complex. The documentation describes the return contract accurately. The gap between documentation and production reality is in how the migration from MQL4 carries a synchronous assumption into an asynchronous execution environment.

We see this pattern causing account state bugs in roughly a third of the MT5 EA rescue work we handle each year. It passes every backtest. It fails at market open. The fix is fewer than 20 lines.