← 7TICS Book a call →
MT5 PROP FIRMS RUN
HEDGING MODE. YOUR
OCO CLOSE IS OPENING
NEW TRADES.

A specific OCO close pattern that works fine on netting MT5 accounts silently fails on hedging accounts — the broker ignores the position field, treats the request as a brand new pending order, and opens an opposing position instead of closing the original. Your account looks like it’s churning. It is — but not how you think.

TL;DR
  • If your prop firm account is on a hedging server — most are — and you send TRADE_ACTION_PENDING with a position field expecting it to manage an open position, MT5 will not manage that position. It opens a new pending order. When it fires, you have two opposing positions, not zero.
  • The fix: use TRADE_ACTION_SLTP to arm or modify stops on an existing position. Use TRADE_ACTION_DEAL with position=<ticket> to close it. Never use TRADE_ACTION_PENDING for either.
  • Detection: scan open positions for sl == 0 when your strategy thinks stops are armed. If you find any, you’ve been hit.

1 · The scene

I run a live algo across FundedNext, E8, Derrick Capital, and LiteFinance. Four books, one Nautilus Trader engine, multiple MT5 terminals on a Linux VPS via Wine. The strategy is a London / Tokyo session range breakout with bracket orders: entry, stop, take-profit, all submitted together.

On three of the four accounts I started seeing something that didn’t add up. Positions that the strategy logs marked as closed by stop were showing up in MT5 as still open, no stop attached. Sometimes a closed position would appear to have spawned a second, opposite one. The journal would say OCO armed. The terminal would say otherwise.

It only happened on three accounts. The fourth — the LiteFinance one — behaved exactly as the strategy expected. That asymmetry was the clue.

2 · Why three brokers and not the fourth

MT5 servers run in one of two position-accounting modes:

The strategy code didn’t care which mode the account was in — or so I thought. The OCO bracket would arm stops on the open position by sending a MqlTradeRequest with action = TRADE_ACTION_PENDING and a position field pointing at the open ticket. On the netting account this silently worked. On hedging it silently broke.

3 · What MT5 actually does with that request

Here is the MqlTradeRequest structure as it’s defined in the MQL5 docs:

struct MqlTradeRequest {
  ENUM_TRADE_REQUEST_ACTIONS action;       // what the request is
  ulong                       magic;
  ulong                       order;
  string                      symbol;
  double                      volume;
  double                      price;
  double                      stoplimit;
  double                      sl;
  double                      tp;
  ulong                       deviation;
  ENUM_ORDER_TYPE             type;
  ENUM_ORDER_TYPE_FILLING     type_filling;
  ENUM_ORDER_TYPE_TIME        type_time;
  datetime                    expiration;
  string                      comment;
  ulong                       position;    // open position ticket
  ulong                       position_by;
};

The action field is the verb. The interesting verbs for managing an existing position are:

That last point is the bug. On a netting server, the broker has only one position per symbol, so even if it ignores position on a pending order, the pending order’s implicit side-effect — when it fires — nets against the existing exposure and the account ends up flat. The bug never surfaces.

On a hedging server, a pending order is just a pending order. When it triggers, it opens a brand new position in the opposite direction. The original position stays open with no stop attached. You now hold two opposing tickets. Spread cost doubled. Risk model broken. Worse: your journal still says “OCO armed” because the broker returned retcode = TRADE_RETCODE_DONE — the pending order was accepted, just not as the operation you thought.

4 · The broken code and the fix

This is roughly what the broken arm-stops path looked like in Python (over rpyc to a Wine-hosted MT5 terminal):

✗ Broken — opens opposite position on hedging

req = {
    "action":   mt5.TRADE_ACTION_PENDING,
    "symbol":   symbol,
    "volume":   pos.volume,
    "type":     mt5.ORDER_TYPE_SELL_STOP,
    "price":    stop_price,
    "sl":       0.0,
    "tp":       0.0,
    "deviation": 10,
    "magic":    pos.magic,
    "position": pos.ticket,    # ignored on hedging
    "comment":  "OCO_STOP",
}
result = mt5.order_send(req)
# retcode == TRADE_RETCODE_DONE
# pending order created — NOT a stop on pos.ticket

✓ Fixed — arms SL/TP on the open position

req = {
    "action":   mt5.TRADE_ACTION_SLTP,
    "symbol":   symbol,
    "position": pos.ticket,
    "sl":       stop_price,
    "tp":       tp_price,
    "magic":    pos.magic,
}
result = mt5.order_send(req)
# retcode == TRADE_RETCODE_DONE
# stop attached to pos.ticket — verifiable via
# mt5.positions_get(ticket=pos.ticket)[0].sl

If you actually want to close the position (instead of arming a stop), the correct verb is TRADE_ACTION_DEAL with the opposite side and the position field set:

req = {
    "action":       mt5.TRADE_ACTION_DEAL,
    "symbol":       symbol,
    "volume":       pos.volume,
    "type":         mt5.ORDER_TYPE_SELL if pos.type == mt5.POSITION_TYPE_BUY else mt5.ORDER_TYPE_BUY,
    "position":     pos.ticket,
    "deviation":    10,
    "type_filling": mt5.ORDER_FILLING_IOC,
    "magic":        pos.magic,
    "comment":      "CLOSE",
}
result = mt5.order_send(req)

For OCO brackets specifically — the original sin behind this whole post — arm the SL with TRADE_ACTION_SLTP at order-fill time, then let the broker manage the close natively. Don’t hand-roll a pending order pretending to be a stop.

5 · How to detect if you’ve been hit

Three signals, in order of how quickly they’ll find the damage:

  1. Naked positions. Scan mt5.positions_get() for any position where sl == 0.0 while your strategy thinks stops are armed. Any hit = you’ve been bitten on that ticket. Reconcile by arming the stop with TRADE_ACTION_SLTP immediately.
  2. Pending-order count mismatch. If your strategy submitted N OCOs and mt5.orders_get() shows N pending orders sitting on the book that you didn’t expect, they’re the ghosts. Cancel them with TRADE_ACTION_REMOVE before any trigger.
  3. Opposing positions on the same symbol. On hedging this is the smoking gun. If your strategy only ever takes one side at a time and you see both a long and a short ticket on the same symbol, the ghost pending fired.

A small reconciler script that runs on a 30-second timer, scans for these three conditions, and emits to whatever monitoring you have, will save you real money. I run one called naked_position_reconciler.py as a systemd timer on the trading host.

6 · Why this isn’t already a stack-overflow answer

Three reasons it stays silent:

The intersection of algo developer × hedging prop account × hand-rolled bracket orders is small. If you’re in it, you’ve probably been bleeding from this for months and assumed it was your strategy.

7 · The bigger pattern

The MQL5 docs technically describe all of this, in a reference page nobody reads end-to-end. The bug is not that the platform behaves badly — it’s that TRADE_RETCODE_DONE is returned for a request that did the wrong thing. The broker did what it was told. You told it the wrong thing. There’s no error to grep.

This is the class of bug that algo systems are full of: success-shaped failures, where every layer in the stack returns OK and the only thing that knows it went wrong is the P&L. The defense isn’t better logs in the calling code. The defense is a reconciler that asks the broker the ground-truth question (“is this position armed?”) and refuses to trust the request you sent.

Same principle behind why my whole trading rig runs an out-of-band HaltActor that owns trading halts independent of the strategy that requested them, and a preflight script that re-checks ticket state before every bar. You can’t close the loop in-process. The process is the thing lying to you.

8 · If this hit you

If you found this because your prop account is bleeding and you think it might be the same bug:

  1. Run mt5.positions_get() right now. Print every ticket where sl == 0. Arm them with TRADE_ACTION_SLTP.
  2. Print every mt5.orders_get() result. Cancel any pending you didn’t place yourself.
  3. Audit your OCO / bracket code for TRADE_ACTION_PENDING with a position field. Replace per the patterns above.
  4. Add a 30-second naked-position reconciler. Even if you fix the calling code, the reconciler catches the next class of success-shaped failure.

I deal with this category of bug for a living. If your stack has shipped two months of P&L into the void and you want a second pair of eyes, the calendar link is in the header.