MQL5 OnTradeTransaction: Master Trade Event Handling in EAs

Learn to use MQL5's OnTradeTransaction handler for precise trade event detection—fills, partial closes, and modifications—with real code and edge-case fixes.

mql5-ontradetransaction-master-trade-event

Why OnTradeTransaction Matters More Than You Think

If you've ever written an MQL5 Expert Advisor that relies on order state changes, you've probably run into the same frustration: OnTick() fires constantly, but you can't easily tell when a trade actually filled, or when a partial close happened. You end up polling PositionSelect() every tick, comparing volumes, and writing messy state-tracking code that breaks as soon as the broker does something weird.

The OnTradeTransaction() event solves this. It's a dedicated handler that fires whenever the trade server reports a transaction — an order placed, filled, modified, or closed. Most EA developers I've seen either ignore it or misuse it, because the documentation is sparse and the event structure looks intimidating at first glance.

I'm going to walk you through exactly how to use it, with real code you can drop into your EAs. No fluff, no theory you'll never use. By the end, you'll know how to detect fills, track partial closes, and avoid the common pitfalls that waste hours of debugging.

Let's be clear: this isn't a magic bullet. You still need to understand order lifecycle, position accounting, and broker-specific quirks. But OnTradeTransaction gives you a clean, event-driven foundation that most retail EAs lack. I've seen too many EAs that check position volume every tick and still miss fills because the check happened between two ticks. That's not robust — it's gambling on timing.

What Is OnTradeTransaction?

In MQL5, OnTradeTransaction() is a special event handler — similar to OnTick() or OnInit() — that the terminal calls automatically when a trade transaction occurs. The function signature looks like this:

void OnTradeTransaction(
    MqlTradeTransaction& trans,
    MqlTradeRequest& request,
    MqlTradeResult& result
);

The three parameters give you everything you need:

  • trans — a structure containing the transaction details: type, order ticket, position ticket, volume, price, time, and more. This is your primary data source.
  • request — the original trade request that triggered this transaction (if any). This can be empty for server-initiated events like stop-outs or margin calls.
  • result — the result of that request, including return code and deal ticket. Useful for correlating your OrderSend calls with server responses.

The key field in trans is trans.type, which is an enum of type ENUM_TRADE_TRANSACTION_TYPE. The most common values you'll work with:

Transaction TypeValueWhat It Means
TRADE_TRANSACTION_ORDER_ADD0A pending order was placed in the system.
TRADE_TRANSACTION_ORDER_DELETE2A pending order was removed (cancelled or expired).
TRADE_TRANSACTION_DEAL_ADD4A deal was executed (order filled, partial fill, or close).
TRADE_TRANSACTION_DEAL_UPDATE5A deal was updated (rare, usually for corrections).
TRADE_TRANSACTION_POSITION6A position was modified (volume, SL/TP, or partial close).

Notice two important things: TRADE_TRANSACTION_DEAL_ADD fires when a deal happens (including fills and closes), while TRADE_TRANSACTION_POSITION fires when the position itself changes — volume changes from partial closes, or SL/TP updates. You need to handle both to get full coverage. Ignoring TRADE_TRANSACTION_POSITION is the most common mistake I see; people only watch for deals and then wonder why their volume tracking drifts.

One more detail: the request parameter is only populated when the transaction was triggered by your EA's own OrderSend call. For server-side events (like a stop-out), request will be empty — request.action == 0. Don't assume request is always valid. Check request.action > 0 before reading its fields.

Practical Implementation: A Working EA Skeleton

Let's build a simple EA that uses OnTradeTransaction to log every transaction and take specific actions on fills and partial closes. I'll keep the trading logic minimal so you can focus on the event handling. This isn't a production EA — it's a learning scaffold you can adapt.

Step 1: Declare the Handler

Add the function to your EA. It must be declared at global scope, just like OnTick. Here's a basic version that prints transaction details to the Experts tab:

void OnTradeTransaction(
    const MqlTradeTransaction& trans,
    const MqlTradeRequest& request,
    const MqlTradeResult& result
)
{
    // Print transaction type and relevant IDs
    Print("Transaction type: ", EnumToString(trans.type),
          ", Order: ", trans.order,
          ", Position: ", trans.position,
          ", Deal: ", trans.deal,
          ", Volume: ", trans.volume);
}

This alone is useful for debugging — you can see exactly when events fire relative to your OrderSend calls. Run it on a demo account with a simple moving average crossover EA and watch the log. You'll see the sequence: TRADE_TRANSACTION_ORDER_ADD (pending order placed), then TRADE_TRANSACTION_DEAL_ADD (filled), then TRADE_TRANSACTION_POSITION (position opened). On a market order, you skip the ORDER_ADD step entirely — the deal and position events come in quick succession.

I recommend adding a timestamp to your prints: Print(TimeToString(TimeCurrent()), " - ..."). This helps correlate events across multiple EAs or manual trades.

Step 2: Detect Order Fills

The most common need: knowing when a market order or pending order actually fills. You want to avoid using OrderSelect in OnTick to check if an order is still alive. Instead, watch for TRADE_TRANSACTION_DEAL_ADD where the deal type is a buy or sell:

void OnTradeTransaction(
    const MqlTradeTransaction& trans,
    const MqlTradeRequest& request,
    const MqlTradeResult& result
)
{
    if(trans.type == TRADE_TRANSACTION_DEAL_ADD)
    {
        // Get deal details
        MqlDeal deal;
        if(HistoryDealSelect(trans.deal))
        {
            if(HistoryDealGetInteger(trans.deal, DEAL_ENTRY) == DEAL_ENTRY_IN)
            {
                ENUM_DEAL_TYPE dealType = (ENUM_DEAL_TYPE)HistoryDealGetInteger(trans.deal, DEAL_TYPE);
                if(dealType == DEAL_TYPE_BUY || dealType == DEAL_TYPE_SELL)
                {
                    double volume = HistoryDealGetDouble(trans.deal, DEAL_VOLUME);
                    double price  = HistoryDealGetDouble(trans.deal, DEAL_PRICE);
                    Print("Order filled: ", EnumToString(dealType),
                          ", Volume: ", volume,
                          ", Price: ", price);
                    // Add your position entry logic here
                }
            }
        }
    }
}

Notice I check DEAL_ENTRY_IN — this filters out deals that are exits (partial or full closes). Without this, you'd trigger your entry logic on every partial close too, which would be a disaster. I've debugged EAs where the developer didn't filter entry type and the EA kept opening new positions every time a partial close happened. That's a fast way to blow an account.

One nuance: on a netting account, a single position can have multiple deals. If you add to a position, you'll get multiple DEAL_ENTRY_IN events on the same position ticket. Your entry logic should handle this gracefully — maybe by updating an average entry price rather than opening a new position.

Step 3: Handle Partial Closes

Partial closes are tricky because they generate multiple transactions. When you close half a position, you get:

  1. A TRADE_TRANSACTION_DEAL_ADD for the closing deal (entry type DEAL_ENTRY_OUT).
  2. A TRADE_TRANSACTION_POSITION with the updated position volume.

To detect a partial close, you need to track the position volume before and after the event. Here's a robust approach using a static variable:

static double lastPositionVolume = 0.0;

void OnTradeTransaction(
    const MqlTradeTransaction& trans,
    const MqlTradeRequest& request,
    const MqlTradeResult& result
)
{
    if(trans.type == TRADE_TRANSACTION_POSITION)
    {
        // Position changed — could be SL/TP update or volume change
        if(PositionSelectByTicket(trans.position))
        {
            double currentVolume = PositionGetDouble(POSITION_VOLUME);
            if(currentVolume < lastPositionVolume)
            {
                double closedVolume = lastPositionVolume - currentVolume;
                Print("Partial close detected: ", closedVolume,
                      " lots closed, remaining: ", currentVolume);
                // Add your partial close handling here
            }
            lastPositionVolume = currentVolume;
        }
    }
    else if(trans.type == TRADE_TRANSACTION_DEAL_ADD)
    {
        // On a new deal, update our volume tracking
        if(PositionSelectByTicket(trans.position))
        {
            lastPositionVolume = PositionGetDouble(POSITION_VOLUME);
        }
    }
}

This works because TRADE_TRANSACTION_POSITION fires after the position volume has been updated. The static variable tracks the last known volume, and any decrease signals a partial close. Reset lastPositionVolume in OnInit() to 0 to avoid stale data on chart switch.

I've seen developers try to detect partial closes by comparing deal volumes. That's fragile — on hedging accounts, a partial close might close an entire sub-position, and the deal volume won't match the volume change on the parent position. The position-level comparison is more reliable.

Also, note that lastPositionVolume is static — it persists across ticks but resets if the EA is reinitialized. If you run multiple EAs on the same chart, each EA has its own static copy, which is fine as long as they don't trade the same symbol. If they do, use a global variable or a file-based tracker to share state.

Step 4: Detect Order Modifications (SL/TP)

Modifying stop loss and take profit on an open position fires TRADE_TRANSACTION_POSITION but the volume doesn't change. To distinguish this from a partial close, check if the SL or TP changed:

if(trans.type == TRADE_TRANSACTION_POSITION)
{
    if(PositionSelectByTicket(trans.position))
    {
        double newSL = PositionGetDouble(POSITION_SL);
        double newTP = PositionGetDouble(POSITION_TP);
        // Compare with stored values (you maintain these)
        if(newSL != storedSL || newTP != storedTP)
        {
            Print("SL/TP modified. New SL: ", newSL, ", TP: ", newTP);
            storedSL = newSL;
            storedTP = newTP;
        }
    }
}

I prefer to store SL/TP values in a class or struct per position ticket, but for simplicity, global variables work in single-position EAs. If you have multiple positions, use an array or a dictionary keyed by ticket number. The storedSL and storedTP variables need to be initialized to 0.0 in OnInit and updated whenever a position is opened.

One gotcha: some brokers round SL/TP values to the nearest pip increment. A modification request might set SL to 1.12345, but the server stores it as 1.1235. Comparing with != will catch this, but if you're using NormalizeDouble with different precision, you might miss it. I recommend comparing with a tolerance: MathAbs

Want to build your own version?

Recreate similar entry logic, risk rules, and filters in TradingBotMaker—no MQL coding. Start free.

Community

Clap for the article and open comments only when you want to read them.

0 claps0 comments

Related articles