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.
- If your prop firm account is on a hedging server — most are — and you send
TRADE_ACTION_PENDINGwith apositionfield 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_SLTPto arm or modify stops on an existing position. UseTRADE_ACTION_DEALwithposition=<ticket>to close it. Never useTRADE_ACTION_PENDINGfor either. - Detection: scan open positions for
sl == 0when 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:
- Netting. One position per symbol. New trades net into the existing position. If you’re long 1 lot EURUSD and you sell 1 lot, you end at flat. Standard for regulated retail and some prop setups.
- Hedging. Many positions per symbol, each with its own ticket. New trades open new positions. If you’re long 1 lot EURUSD and you sell 1 lot, you now hold both a long and a short. Standard for most prop firms (FundedNext, E8, FTMO, Derrick, and others), because it lets traders run multiple strategies on the same symbol without one cannibalising the other.
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:
TRADE_ACTION_DEAL— place a market order. Withpositionset, the broker closes that specific position.TRADE_ACTION_SLTP— modify SL / TP of an open position. Thepositionfield selects which one.TRADE_ACTION_PENDING— place a brand new pending order atprice. Thepositionfield is, on hedging servers, simply ignored.
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:
- Naked positions. Scan
mt5.positions_get()for any position wheresl == 0.0while your strategy thinks stops are armed. Any hit = you’ve been bitten on that ticket. Reconcile by arming the stop withTRADE_ACTION_SLTPimmediately. - 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 withTRADE_ACTION_REMOVEbefore any trigger. - 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:
- Most algo retail trades netting accounts. The bug never surfaces; their code “works.”
- Most prop traders trade discretionary through the UI, not through MQL5. They never assemble an
MqlTradeRequestby hand. - Most prop algo traders use
OrderSendwrappers from libraries like MetaTrader5 Python or various Expert Advisor templates, which paper over the distinction — until they don’t. The wrappers work on the developer’s netting demo and quietly mis-behave on the customer’s hedging live.
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:
- Run
mt5.positions_get()right now. Print every ticket wheresl == 0. Arm them withTRADE_ACTION_SLTP. - Print every
mt5.orders_get()result. Cancel any pending you didn’t place yourself. - Audit your OCO / bracket code for
TRADE_ACTION_PENDINGwith apositionfield. Replace per the patterns above. - 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.