Insights/Insights & Articles/Order Management Patterns Every Algo Trader Must Know

Order Management Patterns Every Algo Trader Must Know

Your entry logic can be brilliant. Your risk model can be airtight. None of it matters if your order management code has a bug that skips closing a position, silently fails to open one, or calculates lots wrong and sends your account to zero.

Mar 9, 2026

Order Management Patterns Every Algo Trader Must Know

Order Management Patterns Every Algo Trader Must Know

Your entry logic can be brilliant. Your risk model can be airtight. None of it matters if your order management code has a bug that skips closing a position. Or silently fails to open one. Or calculates lots wrong and sends your account to zero.

We've audited hundreds of EAs over the years. The thing that separates the ones that survive from the ones that implode? Not the strategy. The plumbing. Here are eight patterns we consider non-negotiable.

Pattern 1: Magic Number Tagging

Every EA on your MetaTrader terminal dips into the same OrdersTotal() pool. Without some way to tell whose order is whose, it's a free-for-all. Your scalper closes your swing trader's position. Your hedge gets flattened by your trend-follower.

Magic numbers solve this. Tag every order with an integer ID at creation. Three approaches we've used in production:

Static Input Magic (Simplest)

input int MagicNumber = 20251025;   // Unique per EA instance
 
int ticket = OrderSend(Symbol(), OP_BUY, lots, Ask, 3,
                       sl, tp, "My EA Buy",
                       MagicNumber,        // <-- tag the order
                       0, clrBlue);
 
// Later, when filtering:
for (int i = OrdersTotal() - 1; i >= 0; i--)
{
    if (!OrderSelect(i, SELECT_BY_POS, MODE_TRADES)) continue;
    if (OrderMagicNumber() != MagicNumber) continue;   // skip other EAs
    if (OrderSymbol() != Symbol()) continue;            // skip other charts
    // This order belongs to us
}

Always filter by both magic number AND symbol. Two instances of the same EA on EURUSD and GBPUSD share the magic number. Without the symbol check, they'll step on each other's trades.

Runtime-Switching Magic (For Grouped Orders)

Some strategies need order groups – tag the market entry with one magic, the hedge with another. Then you can close one group without touching the other:

int MagicGroup;
 
void OnTick()
{
    if (OrdersTotal() == 0)
    {
        MagicGroup = 1;
        PlaceBuyMarket();      // Tagged with magic 1
        PlaceBuyLimit();       // Tagged with magic 1
 
        MagicGroup = 2;
        PlaceBuyLimit2();      // Tagged with magic 2
        PlaceSellStop();       // Tagged with magic 2
    }
}

Close group 1 while keeping group 2 alive. Or vice versa. The magic number is the leash.

Offset-Based Magic (For Multi-Strategy EAs)

One EA, three strategies, zero interference:

input int MagicBase = 6614;
 
// Strategy A uses magic 6614
OrderSend(..., MagicBase + 0, ...);
 
// Strategy B uses magic 6615
OrderSend(..., MagicBase + 1, ...);
 
// Strategy C uses magic 6616
OrderSend(..., MagicBase + 2, ...);

Each strategy manages its own orders without knowing the others exist.

Pattern 2: The Backward-Loop Close

If you only learn one pattern from this entire article, make it this one. We're not exaggerating when we say we've seen this bug in most of the EAs we've audited.

The Bug Everyone Hits

// WRONG: forward loop
for (int i = 0; i < OrdersTotal(); i++)
{
    OrderSelect(i, SELECT_BY_POS, MODE_TRADES);
    if (OrderType() == OP_BUY)
        OrderClose(OrderTicket(), OrderLots(),
                   MarketInfo(OrderSymbol(), MODE_BID), 5, Red);
}

What happens: Close order at index 0. The order that was at index 1 slides down to index 0. Loop increments to i=1. You just skipped an order. Four positions, two closed, two still dangling. Surprise.

The Fix: Go Backwards

// CORRECT: backward loop
for (int i = OrdersTotal() - 1; i >= 0; i--)
{
    if (!OrderSelect(i, SELECT_BY_POS, MODE_TRADES)) continue;
 
    if (OrderMagicNumber() != MagicNumber) continue;
    if (OrderSymbol() != Symbol()) continue;
 
    switch (OrderType())
    {
        case OP_BUY:
            OrderClose(OrderTicket(), OrderLots(),
                      MarketInfo(OrderSymbol(), MODE_BID), 5, Red);
            break;
        case OP_SELL:
            OrderClose(OrderTicket(), OrderLots(),
                      MarketInfo(OrderSymbol(), MODE_ASK), 5, Red);
            break;
        case OP_BUYLIMIT:
        case OP_BUYSTOP:
        case OP_SELLLIMIT:
        case OP_SELLSTOP:
            OrderDelete(OrderTicket());
            break;
    }
}

Close index 4, indices 0–3 stay put. Decrement to 3. Everything's where you expect it. Simple, elegant, correct.

The switch handles all six order types – market orders go through OrderClose() (with the correct bid/ask), pendings go through OrderDelete().

Python Equivalent

def close_all_positions(self, exchange, magic_tag=None):
    """Close all open positions, newest first."""
    positions = exchange.fetch_positions()
 
    # Sort by timestamp descending (equivalent to backward loop)
    positions.sort(key=lambda p: p['timestamp'], reverse=True)
 
    for pos in positions:
        if magic_tag and pos.get('info', {}).get('clientId') != magic_tag:
            continue
 
        side = 'sell' if pos['side'] == 'long' else 'buy'
        exchange.create_market_order(
            pos['symbol'], side, abs(pos['contracts']))

Pattern 3: Lot Splitting

Most brokers cap how much you can send per order. When your calculated lots exceed the broker's max, you split into multiple orders. This matters more than you'd think – a $500K HFT account with a 2× multiplier can easily need 760 lots. That's eight orders of 100.

#define MAX_LOT_PER_ORDER  100.0   // Broker's per-order maximum
 
void PlaceBuy(double totalLots)
{
    double remaining = totalLots;
    double lotsSent = 0;
 
    while (remaining > 0)
    {
        double thisLot;
        if (remaining > MAX_LOT_PER_ORDER)
        {
            thisLot = MAX_LOT_PER_ORDER;
            remaining -= MAX_LOT_PER_ORDER;
        }
        else
        {
            thisLot = remaining;
            remaining = 0;
        }
 
        int ticket = OrderSend(Symbol(), OP_BUY, thisLot,
                               Ask, 3, 0, 0, NULL, MagicNumber,
                               0, clrBlue);
 
        if (ticket > 0)
            lotsSent += thisLot;
        else
            Print("Lot split order failed. Error: ", GetLastError());
    }
 
    Print("Total lots deployed: ", lotsSent, " across multiple orders");
}

When you need this: HFT strategies on large accounts where AccountBalance() * multiplier / divisor exceeds broker limits. A $500K account with a 2× multiplier might need 760 lots – split into 8 orders of 100 lots each.

Pattern 4: The Triple Accumulator

Counting orders isn't enough. In production, we need three things in a single pass: order count, total lot exposure, and net P&L – including commissions and swap. Skip the commission/swap part and your equity calculations will be lying to you.

We see this mistake constantly: an EA checks OrderProfit() and declares the position up $50. Meanwhile, $30 in commissions and -$5 in swap fees bring the real number to $15. Live performance "mysteriously" underperforms the backtest.

double totalLots   = 0;
double totalProfit = 0;
int    orderCount  = 0;
 
for (int i = OrdersTotal() - 1; i >= 0; i--)
{
    if (!OrderSelect(i, SELECT_BY_POS, MODE_TRADES)) continue;
    if (OrderMagicNumber() != MagicNumber) continue;
    if (OrderSymbol() != Symbol()) continue;
    if (OrderType() != OP_BUY) continue;
 
    totalProfit += OrderProfit() + OrderCommission() + OrderSwap();
    totalLots   += OrderLots();
    orderCount++;
}

Always add OrderCommission() + OrderSwap(). Always. This is not optional.

Python Equivalent

def get_position_summary(self, positions, side='long'):
    """Triple accumulator: count, lots, net P&L."""
    count = 0
    total_size = 0.0
    total_pnl = 0.0
 
    for pos in positions:
        if pos['side'] != side:
            continue
        count += 1
        total_size += abs(pos['contracts'])
        total_pnl += (pos.get('unrealizedPnl', 0)
                      - pos.get('fee', 0))
 
    return {
        'count': count,
        'total_size': total_size,
        'net_pnl': total_pnl
    }

Pattern 5: Trailing Stop Variants

Three approaches we've used, from simple to borderline obsessive:

A. Standard OrderModify Trailing

The workhorse. Move the stop behind price as profit grows. Two rules that cannot be broken: for buys, the stop only moves up. For sells, only down. Violate this and your trailing stop becomes a trailing target.

void ManageTrailingStops()
{
    for (int i = 0; i < OrdersTotal(); i++)
    {
        if (!OrderSelect(i, SELECT_BY_POS, MODE_TRADES)) continue;
        if (OrderMagicNumber() != MagicNumber) continue;
        if (OrderSymbol() != Symbol()) continue;
 
        // Only trail after minimum profit threshold
        double profitPoints;
        if (OrderType() == OP_BUY)
            profitPoints = (Bid - OrderOpenPrice()) / Point;
        else if (OrderType() == OP_SELL)
            profitPoints = (OrderOpenPrice() - Ask) / Point;
        else continue;
 
        if (profitPoints <= TrailingStartPoints) continue;
 
        // Calculate new stop
        double newSl;
        if (OrderType() == OP_BUY)
        {
            newSl = NormalizeDouble(Bid - TrailingStepPoints * Point, Digits);
            if (newSl > OrderStopLoss())   // Only move stop UP for buys
                OrderModify(OrderTicket(), OrderOpenPrice(),
                           newSl, OrderTakeProfit(), 0, clrBlue);
        }
        else
        {
            newSl = NormalizeDouble(Ask + TrailingStepPoints * Point, Digits);
            if (newSl < OrderStopLoss() || OrderStopLoss() == 0)
                OrderModify(OrderTicket(), OrderOpenPrice(),
                           newSl, OrderTakeProfit(), 0, clrRed);
        }
    }
}

Two important details: the TrailingStartPoints threshold gives the trade room to breathe before you start chasing it with a stop. And the OrderStopLoss() == 0 check handles orders that came in without an initial SL.

B. Delete-and-Replace Trailing

Sometimes you can't OrderModify() a pending order's price. The workaround: delete it and place a fresh one.

void TrailPendingOrders()
{
    for (int i = OrdersTotal() - 1; i >= 0; i--)
    {
        if (!OrderSelect(i, SELECT_BY_POS, MODE_TRADES)) continue;
 
        // Trail buy limits upward when price moves up
        if (OrderType() == OP_BUYLIMIT && Close[0] > OrderOpenPrice())
        {
            OrderDelete(OrderTicket());
            // Place new buy limit at current distance below price
            double newPrice = Close[0] - LimitDistance * Point;
            OrderSend(Symbol(), OP_BUYLIMIT, OrderLots(),
                     newPrice, 3, 0, 0, NULL, MagicNumber, 0, clrBlue);
        }
    }
}

C. Multi-Breakpoint Trailing

The fancy version. Different trail distances at different profit levels. Loose when you're barely in profit. Tight when you're deep in the green.

class MultiBreakpointTrail:
    """
    Trail at different distances based on profit tiers.
    Config: [(profit_pips, trail_distance), ...]
    Example: [(20, 15), (40, 10), (60, 5)]
      - After 20 pips profit: trail 15 pips behind
      - After 40 pips profit: trail 10 pips behind
      - After 60 pips profit: trail 5 pips behind (tightest)
    """
 
    def __init__(self, breakpoints):
        # Sort descending by profit threshold
        self.breakpoints = sorted(breakpoints, reverse=True)
 
    def calculate_stop(self, entry_price, current_price, side='long'):
        profit_pips = abs(current_price - entry_price) * 10000
 
        for threshold, distance in self.breakpoints:
            if profit_pips >= threshold:
                if side == 'long':
                    return current_price - (distance / 10000)
                else:
                    return current_price + (distance / 10000)
 
        return None  # No trailing yet

Pattern 6: Equity-Based Exit (No SL/TP)

Basket strategies are weird. You deliberately send orders with SL=0, TP=0 and manage everything through total account equity. Why? Because in a basket of 8 correlated pairs, individual pair P&L is noise. Pair #3 might be bleeding $200 while the other seven are collectively up $350. Kill pair #3's stop and you destroy the hedge.

void OnTick()
{
    double balance = AccountBalance();
    double equity  = AccountEquity();
 
    // Combined profit target across ALL orders
    if (equity > balance + ProfitTarget)
    {
        CloseAllBuy();
        CloseAllSell();
        Print("Combined basket profit target hit");
    }
 
    // Combined loss limit across ALL orders
    if (equity < balance - MaxLoss)
    {
        CloseAllBuy();
        CloseAllSell();
        Print("Combined basket loss limit hit");
    }
}

The basket lives or dies as a unit. Not piecemeal.

Pattern 7: OrderSend Error Handling

This one makes us genuinely angry. Most EAs we audit have zero error handling on OrderSend(). The function returns -1, and the EA barrels ahead assuming the order exists. Then wonders why trades are "missing."

The Wrong Way (Depressingly Common)

// Captures ticket but never checks it
int status3 = OrderSend(Symbol(), OP_BUY, LOT, Ask, 0, 0, 0,
                        NULL, 1, 0, clrBlue);
// Proceeds to next logic assuming the order exists

The Right Way

Check the ticket. Log the error. Know what went wrong.

int ticket = OrderSend(Symbol(), OP_BUY,
                       NormalizeDouble(lots, 2),  // Always normalize
                       Ask,
                       Slippage,                  // Never use 0
                       sl, tp,
                       "EA Buy",
                       MagicNumber,
                       0, clrBlue);
 
if (ticket > 0)
{
    Print("Order opened: ticket=", ticket,
          " lots=", lots,
          " price=", Ask);
}
else
{
    int err = GetLastError();
    Print("OrderSend FAILED. Error ", err, ": ", ErrorDescription(err));
 
    // Common errors and recovery:
    // 130 = Invalid stops (SL/TP too close to price)
    // 131 = Invalid trade volume
    // 134 = Not enough money
    // 138 = Requote
    // 146 = Trade context busy (retry after Sleep)
}

Pre-Trade Validation

Don't wait for the broker to reject you. Check before you ask.

bool CanOpenNewTrade(double lots)
{
    if (AccountFreeMargin() < MinimumFreeMargin)
    {
        Print("Insufficient free margin: ", AccountFreeMargin());
        return false;
    }
 
    if (lots < MarketInfo(Symbol(), MODE_MINLOT))
    {
        Print("Lots ", lots, " below min ", MarketInfo(Symbol(), MODE_MINLOT));
        return false;
    }
 
    if (lots > MarketInfo(Symbol(), MODE_MAXLOT))
    {
        Print("Lots ", lots, " above max ", MarketInfo(Symbol(), MODE_MAXLOT));
        return false;
    }
 
    return true;
}

It's cheaper to check MODE_MINLOT and free margin before the network round-trip than to deal with the rejection after.

Pattern 8: Selective Order Closing

Sometimes you need surgical precision. Not "close everything" but "close only the worst-performing buy" or "close only the order with the highest entry price." Two-pass approach: find first, close second.

void CloseHighestBuy()
{
    double highestPrice = 0;
    int    targetTicket = 0;
    double targetLots = 0;
 
    // Pass 1: Find the buy with the highest open price
    for (int i = OrdersTotal() - 1; i >= 0; i--)
    {
        if (!OrderSelect(i, SELECT_BY_POS, MODE_TRADES)) continue;
        if (OrderType() != OP_BUY) continue;
        if (OrderMagicNumber() != MagicNumber) continue;
 
        if (OrderOpenPrice() > highestPrice)
        {
            highestPrice  = OrderOpenPrice();
            targetTicket  = OrderTicket();
            targetLots    = OrderLots();
        }
    }
 
    // Pass 2: Close just that one order
    if (targetTicket > 0)
        OrderClose(targetTicket, targetLots,
                  MarketInfo(Symbol(), MODE_BID), 5, Red);
}

Find the target in pass one. Close it in pass two. Safe, predictable, no surprises. Pair this with mirror functions – CloseLowestBuy, CloseHighestSell, CloseLowestSell – and you can peel orders off a basket one at a time.

The Checklist

PatternPurposeBug It Prevents
Magic number filteringIsolate EA's ordersCross-EA interference
Backward loopSafe order deletionSkipped orders from index shifting
Lot splittingBroker limit complianceRejected large orders
Triple accumulatorFull position awarenessP&L miscalculation (missing commissions)
Trailing stop (with threshold)Lock in profitsPremature stop movement
Equity-based exitBasket managementBreaking hedge geometry
OrderSend validationReliable executionSilent order failures
Two-pass selective closeSurgical position managementWrong order closed

Get Your EA Audited

If your bot is missing any of these – especially the backward loop and error handling – there are bugs hiding in there. It's not a matter of if they bite you. It's when.

We tear apart existing EAs, find the order management holes, and rebuild to production grade.

Request an EA Audit →

Algorithmic Trading Insights by Quantumcona.

1of4