Building a Basket Order Manager in MQL5 for Correlated Position Groups
Introduction
MetaTrader 5 treats every open position independently. This creates a practical problem for traders running correlated baskets. Examples include three USD pairs during a fundamental dollar move or a gold position hedged against equity longs. There is no native way to sum floating P&L across a group, no way to say "close everything when the basket loses $200," and no coordinated close mechanism. Closing a basket manually means selecting each position and clicking close individually, during which price can move between the first and last execution.
This article builds CBasketManager. The class groups positions by a basket identifier stored in the position comment, tracks aggregate floating P&L and volume, applies a unified equity stop for the whole basket, and closes all legs in a coordinated sequence. The system splits into four focused components: a scanner that reads positions, an executor that sends orders, a stop registry that monitors thresholds, and a dashboard that renders live state as a chart comment. The demo EA opens legs across two symbols — EURUSD and a configurable gold symbol — to demonstrate the engine managing positions on different instruments simultaneously.

Architectural diagram showing how the strategy layer delegates to CBasketManager, which coordinates position scanning and stop monitoring before passing aggregate state through CBasketInfo to the execution layer.
The Basket ID Convention
Basket membership is stored in POSITION_COMMENT. Every leg belonging to a basket is opened with a comment in the format "BASKET:ID", for example "BASKET:USD_LONG" or "BASKET:GOLD_HEDGE". The scanner identifies basket legs by calling StringFind() to locate the "BASKET:" prefix in the comment, then StringSubstr() to extract everything after it as the ID:
int pos = ::StringFind(comment, "BASKET:"); int id_start = pos + ::StringLen("BASKET:"); string id = ::StringSubstr(comment, id_start);
If StringFind() returns -1, the position carries no basket prefix and is ignored. If the prefix is present but the ID portion is empty, the comment is exactly "BASKET:", so the extracted string is also empty and ignored.
The comment field has a 255-character limit and on some accounts the broker appends confirmation text after the order is placed. Keeping the basket ID short, four to twelve characters, leaves room for broker-appended text and avoids truncation.
CBasketInfo — the Aggregate State Snapshot
Every scan returns a CBasketInfo struct rather than individual values. This gives callers everything they need to make decisions and render dashboards in a single call.
//+------------------------------------------------------------------+ //| BasketInfo.mqh | //+------------------------------------------------------------------+ #ifndef BASKETINFO_MQH #define BASKETINFO_MQH //+------------------------------------------------------------------+ //| CBasketInfo | //| Snapshot of one basket's current aggregate state. Populated by | //| CBasketScanner on each scan. All monetary values are in account | //| currency. Volume-weighted pip P&L is computed across all legs. | //+------------------------------------------------------------------+ struct CBasketInfo { string basket_id; // the basket identifier, e.g. "USD_LONG" int legs; // number of open positions in this basket double total_long_volume; // sum of volumes for all BUY legs double total_short_volume; // sum of volumes for all SELL legs double aggregate_pnl; // total floating P&L in account currency double vw_pips; // volume-weighted average pip P&L across all legs double stop_threshold; // equity stop threshold set via ModifyBasketSL() double distance_to_stop; // aggregate_pnl - stop_threshold (positive = safe) datetime last_scan; // timestamp of the most recent scan CBasketInfo(void) { basket_id = ""; legs = 0; total_long_volume = 0.0; total_short_volume = 0.0; aggregate_pnl = 0.0; vw_pips = 0.0; stop_threshold = 0.0; distance_to_stop = 0.0; last_scan = 0; } ~CBasketInfo(void) { } }; #endif // BASKETINFO_MQH //+------------------------------------------------------------------+
aggregate_pnl is the simple sum of POSITION_PROFIT across all legs in account currency. vw_pips is the volume-weighted average pip P&L. A basket with a 0.10 lot long at +25 pips and a 0.20 lot long at -8 pips produces (0.10×25 + 0.20×(-8)) / 0.30 = 3.00 pips. This normalizes pip performance across legs of different sizes.
distance_to_stop is aggregate_pnl − stop_threshold. When positive, the basket can lose that many more dollars before the stop fires. When negative, the threshold has been breached.
CBasketScanner — Position Discovery
CBasketScanner has one responsibility: reading the open position list and grouping positions by basket ID. It performs no trading operations.
//+------------------------------------------------------------------+ //| BasketScanner.mqh | //+------------------------------------------------------------------+ #ifndef BASKETSCANNER_MQH #define BASKETSCANNER_MQH #include "BasketInfo.mqh" //+------------------------------------------------------------------+ //| CBasketScanner | //+------------------------------------------------------------------+ class CBasketScanner { private: string m_prefix; // the comment prefix used to mark basket legs string ExtractBasketId(const string comment) const; public: CBasketScanner(void); ~CBasketScanner(void); //--- basket query operations bool GetBasketInfo(const string basket_id, CBasketInfo &info) const; int GetAllBaskets(CBasketInfo &baskets[]) const; bool IsBasketLeg(const string comment) const; };
ExtractBasketId() locates the prefix with StringFind() and takes the remainder with StringSubstr(). It is private, callers use the public methods and never need raw ID extraction.
//+------------------------------------------------------------------+ //| ExtractBasketId | //+------------------------------------------------------------------+ string CBasketScanner::ExtractBasketId(const string comment) const { int pos = ::StringFind(comment, m_prefix); if(pos < 0) return(""); int id_start = pos + ::StringLen(m_prefix); string id = ::StringSubstr(comment, id_start); return(id); }
GetBasketInfo() iterates PositionsTotal() and selects each position by ticket. It compares the extracted comment ID with the requested basket ID. For matching positions, it sums POSITION_PROFIT into aggregate_pnl, splits volume by POSITION_TYPE, and computes each leg's volume-weighted pip contribution.
//+------------------------------------------------------------------+ //| GetBasketInfo | //+------------------------------------------------------------------+ bool CBasketScanner::GetBasketInfo(const string basket_id, CBasketInfo &info) const { info.basket_id = basket_id; info.legs = 0; info.total_long_volume = 0.0; info.total_short_volume = 0.0; info.aggregate_pnl = 0.0; info.vw_pips = 0.0; info.last_scan = ::TimeCurrent(); double weighted_pip_sum = 0.0; double total_volume = 0.0; int total = ::PositionsTotal(); for(int i = 0; i < total; i++) { ulong ticket = ::PositionGetTicket(i); if(ticket == 0) continue; if(!::PositionSelectByTicket(ticket)) continue; string comment = ::PositionGetString(POSITION_COMMENT); if(ExtractBasketId(comment) != basket_id) continue; //--- this position belongs to the basket long pos_type = ::PositionGetInteger(POSITION_TYPE); double volume = ::PositionGetDouble(POSITION_VOLUME); double pnl = ::PositionGetDouble(POSITION_PROFIT); double open_px = ::PositionGetDouble(POSITION_PRICE_OPEN); double cur_px = ::PositionGetDouble(POSITION_PRICE_CURRENT); string symbol = ::PositionGetString(POSITION_SYMBOL); double point = ::SymbolInfoDouble(symbol, SYMBOL_POINT); int digits = (int)::SymbolInfoInteger(symbol, SYMBOL_DIGITS); double pip_size = (digits == 3 || digits == 5) ? point * 10.0 : point; //--- compute pip P&L for this leg double pip_pnl = 0.0; if(pip_size > 0.0) { if(pos_type == POSITION_TYPE_BUY) pip_pnl = (cur_px - open_px) / pip_size; else pip_pnl = (open_px - cur_px) / pip_size; } info.legs++; info.aggregate_pnl += pnl; if(pos_type == POSITION_TYPE_BUY) info.total_long_volume += volume; else info.total_short_volume += volume; weighted_pip_sum += volume * pip_pnl; total_volume += volume; } if(info.legs == 0) return(false); if(total_volume > 0.0) info.vw_pips = weighted_pip_sum / total_volume; info.distance_to_stop = info.aggregate_pnl - info.stop_threshold; return(true); }
GetAllBaskets() makes one pass through all open positions, collects every distinct basket ID it encounters, then calls GetBasketInfo() once per unique ID. The result is a dynamically sized array of CBasketInfo structs, one per active basket.
//+------------------------------------------------------------------+ //| GetAllBaskets | //+------------------------------------------------------------------+ int CBasketScanner::GetAllBaskets(CBasketInfo &baskets[]) const { string found_ids[]; int id_count = 0; int total = ::PositionsTotal(); for(int i = 0; i < total; i++) { ulong ticket = ::PositionGetTicket(i); if(ticket == 0) continue; if(!::PositionSelectByTicket(ticket)) continue; string comment = ::PositionGetString(POSITION_COMMENT); string id = ExtractBasketId(comment); if(::StringLen(id) == 0) continue; //--- check if this ID is already in our found list bool already = false; for(int j = 0; j < id_count; j++) { if(found_ids[j] == id) { already = true; break; } } if(!already) { ::ArrayResize(found_ids, id_count + 1); found_ids[id_count] = id; id_count++; } } ::ArrayResize(baskets, id_count); for(int i = 0; i < id_count; i++) GetBasketInfo(found_ids[i], baskets[i]); return(id_count); }
CBasketExecutor — Trade Execution
CBasketExecutor sends orders and never reads positions for analytical purposes.
//+------------------------------------------------------------------+ //| BasketExecutor.mqh | //+------------------------------------------------------------------+ #ifndef BASKETEXECUTOR_MQH #define BASKETEXECUTOR_MQH #include "BasketInfo.mqh" //+------------------------------------------------------------------+ //| CBasketExecutor | //+------------------------------------------------------------------+ class CBasketExecutor { private: string m_prefix; // basket comment prefix int m_slippage; // max slippage in points for market orders ulong m_magic; // magic number for orders opened by this executor bool ClosePosition(ulong ticket, const string symbol, long pos_type, double volume); ENUM_ORDER_TYPE_FILLING ResolveFilling(const string symbol) const; public: CBasketExecutor(void); ~CBasketExecutor(void); //--- configuration void Configure(const int slippage, const ulong magic); //--- basket operations bool CloseBasket(const string basket_id); ulong OpenBasketLeg(const string basket_id, const string symbol, ENUM_ORDER_TYPE order_type, double volume, double price, double sl, double tp); };
ResolveFilling() reads SYMBOL_FILLING_MODE and returns the first supported filling mode in order of preference: FOK, then IOC, then RETURN as a fallback. This prevents retcode 10030 errors on brokers that do not support ORDER_FILLING_FOK for forex symbols, which is the most common cause of order failures when the filling mode is hardcoded.
//+------------------------------------------------------------------+ //| ResolveFilling | //+------------------------------------------------------------------+ ENUM_ORDER_TYPE_FILLING CBasketExecutor::ResolveFilling(const string symbol) const { uint filling = (uint)::SymbolInfoInteger(symbol, SYMBOL_FILLING_MODE); //--- bit 0 set means FOK is supported if((filling & 1) != 0) return(ORDER_FILLING_FOK); //--- bit 1 set means IOC is supported if((filling & 2) != 0) return(ORDER_FILLING_IOC); //--- RETURN is the universal fallback accepted by all brokers return(ORDER_FILLING_RETURN); }
ClosePosition() builds an MqlTradeRequest with TRADE_ACTION_DEAL and submits the closing order. A POSITION_TYPE_BUY is closed with ORDER_TYPE_SELL at the current bid; a sell leg is closed with a buy at the current ask.
//+------------------------------------------------------------------+ //| ClosePosition | //+------------------------------------------------------------------+ bool CBasketExecutor::ClosePosition(ulong ticket, const string symbol, long pos_type, double volume) { MqlTradeRequest req; MqlTradeResult res; ::ZeroMemory(req); ::ZeroMemory(res); req.action = TRADE_ACTION_DEAL; req.position = ticket; req.symbol = symbol; req.volume = volume; req.deviation = m_slippage; req.magic = m_magic; req.comment = "basket close"; req.type_filling = ResolveFilling(symbol); //--- close a BUY with a SELL and vice versa if(pos_type == POSITION_TYPE_BUY) { req.type = ORDER_TYPE_SELL; req.price = ::SymbolInfoDouble(symbol, SYMBOL_BID); } else { req.type = ORDER_TYPE_BUY; req.price = ::SymbolInfoDouble(symbol, SYMBOL_ASK); } return(::OrderSend(req, res) && res.retcode == TRADE_RETCODE_DONE); }
CloseBasket() iterates positions in reverse index order (from PositionsTotal()-1 to 0) to avoid index shifts when positions are removed mid-loop. For each matching leg, it records floating P&L before closing, calls ClosePosition(), and logs the ticket. After the loop, it logs total realized P&L for the basket.
//+------------------------------------------------------------------+ //| CloseBasket | //+------------------------------------------------------------------+ bool CBasketExecutor::CloseBasket(const string basket_id) { string target_prefix = m_prefix + basket_id; double total_pnl = 0.0; int closed_count = 0; bool any_failed = false; PrintFormat("CBasketExecutor: closing basket '%s'", basket_id); //--- iterate positions; loop backward so index stays valid after any external close int total = ::PositionsTotal(); for(int i = total - 1; i >= 0; i--) { ulong ticket = ::PositionGetTicket(i); if(ticket == 0) continue; if(!::PositionSelectByTicket(ticket)) continue; string comment = ::PositionGetString(POSITION_COMMENT); if(::StringFind(comment, target_prefix) < 0) continue; string symbol = ::PositionGetString(POSITION_SYMBOL); long pos_type = ::PositionGetInteger(POSITION_TYPE); double volume = ::PositionGetDouble(POSITION_VOLUME); double pnl = ::PositionGetDouble(POSITION_PROFIT); total_pnl += pnl; bool ok = ClosePosition(ticket, symbol, pos_type, volume); if(ok) { closed_count++; PrintFormat("CBasketExecutor: closed ticket=%llu symbol=%s volume=%.2f pnl=%.2f", ticket, symbol, volume, pnl); } else { any_failed = true; PrintFormat("CBasketExecutor: failed to close ticket=%llu symbol=%s", ticket, symbol); } } PrintFormat("CBasketExecutor: basket '%s' closed %d legs, total realized P&L=%.2f %s", basket_id, closed_count, total_pnl, ::AccountInfoString(ACCOUNT_CURRENCY)); return(!any_failed && closed_count > 0); }
OpenBasketLeg() prepends the basket comment automatically — m_prefix + basket_id — before submitting the order. The caller supplies all other parameters. This ensures no leg can be opened without a basket comment, making the convention self-enforcing.
//+------------------------------------------------------------------+ //| OpenBasketLeg | //+------------------------------------------------------------------+ ulong CBasketExecutor::OpenBasketLeg(const string basket_id, const string symbol, ENUM_ORDER_TYPE order_type, double volume, double price, double sl, double tp) { MqlTradeRequest req; MqlTradeResult res; ::ZeroMemory(req); ::ZeroMemory(res); string comment = m_prefix + basket_id; req.action = TRADE_ACTION_DEAL; req.symbol = symbol; req.volume = volume; req.type = order_type; req.price = price; req.sl = sl; req.tp = tp; req.deviation = m_slippage; req.magic = m_magic; req.comment = comment; req.type_filling = ResolveFilling(symbol); if(!::OrderSend(req, res)) { PrintFormat("CBasketExecutor: OpenBasketLeg failed for basket '%s' symbol=%s retcode=%d", basket_id, symbol, res.retcode); return(0); } PrintFormat("CBasketExecutor: opened leg basket='%s' symbol=%s type=%s vol=%.2f ticket=%llu", basket_id, symbol, ::EnumToString(order_type), volume, res.deal); return(res.deal); }
CBasketStopRegistry — Unified Basket Stops
CBasketStopRegistry stores a per-basket equity threshold and evaluates every registered stop against current aggregate P&L on demand.
//+------------------------------------------------------------------+ //| BasketStopRegistry.mqh | //+------------------------------------------------------------------+ #ifndef BASKETSTOPREGISTRY_MQH #define BASKETSTOPREGISTRY_MQH #include "BasketInfo.mqh" //--- Function pointer type for the close callback //--- Declared at file scope so both CBasketStopRegistry and CBasketManager can access it typedef void (*BasketCloseCallback)(const string basket_id); //+------------------------------------------------------------------+ //| CBasketStopRegistry | //+------------------------------------------------------------------+ class CBasketStopRegistry { private: struct SStopEntry { string basket_id; double threshold; // close basket when aggregate P&L falls below this bool active; }; SStopEntry m_entries[]; int m_count; int m_capacity; BasketCloseCallback m_callback; // called when a basket breaches its stop int FindEntry(const string basket_id) const; void GrowIfNeeded(void); public: CBasketStopRegistry(void); ~CBasketStopRegistry(void); //--- registry management void SetCallback(BasketCloseCallback callback); void Register(const string basket_id, const double threshold); bool Deregister(const string basket_id); bool GetThreshold(const string basket_id, double &threshold) const; void CheckAll(const CBasketInfo &baskets[], const int count); int Count(void) const; };
Register() stores a basket ID and its equity stop threshold. Re-registering an existing basket ID updates the threshold without adding a duplicate entry.
//+------------------------------------------------------------------+ //| Register | //+------------------------------------------------------------------+ void CBasketStopRegistry::Register(const string basket_id, const double threshold) { int idx = FindEntry(basket_id); if(idx >= 0) { m_entries[idx].threshold = threshold; return; } GrowIfNeeded(); m_entries[m_count].basket_id = basket_id; m_entries[m_count].threshold = threshold; m_entries[m_count].active = true; m_count++; PrintFormat("CBasketStopRegistry: registered stop for '%s' at %.2f", basket_id, threshold); }
CheckAll() takes a snapshot array of CBasketInfo structs and evaluates each registered stop against the matching basket's aggregate_pnl. The breach condition is strict: aggregate_pnl < threshold. A basket sitting exactly at its threshold is not triggered. When a breach is detected, m_callback is invoked with the basket ID.
//+------------------------------------------------------------------+ //| CheckAll | //+------------------------------------------------------------------+ void CBasketStopRegistry::CheckAll(const CBasketInfo &baskets[], const int count) { for(int i = 0; i < m_count; i++) { if(!m_entries[i].active) continue; string id = m_entries[i].basket_id; double threshold = m_entries[i].threshold; //--- find this basket in the provided info array for(int j = 0; j < count; j++) { if(baskets[j].basket_id != id) continue; if(baskets[j].aggregate_pnl < threshold) { PrintFormat("CBasketStopRegistry: '%s' breached stop threshold %.2f (current P&L %.2f) — triggering close", id, threshold, baskets[j].aggregate_pnl); if(m_callback != NULL) m_callback(id); } break; } } }
The callback mechanism keeps CBasketStopRegistry decoupled from CBasketExecutor. The registry knows nothing about how baskets are closed, it only signals that a threshold has been breached.
CBasketManager — the Public Interface
CBasketManager owns the scanner, executor, and registry and exposes the complete API that strategy code interacts with.
//+------------------------------------------------------------------+ //| BasketManager.mqh | //+------------------------------------------------------------------+ #ifndef BASKETMANAGER_MQH #define BASKETMANAGER_MQH #include "BasketInfo.mqh" #include "BasketScanner.mqh" #include "BasketExecutor.mqh" #include "BasketStopRegistry.mqh" //+------------------------------------------------------------------+ //| CBasketManager | //+------------------------------------------------------------------+ class CBasketManager { private: CBasketScanner m_scanner; CBasketExecutor m_executor; CBasketStopRegistry m_registry; //--- static trampoline used to satisfy the function-pointer callback interface static CBasketManager *s_instance; static void CloseCallback(const string basket_id); public: CBasketManager(void); ~CBasketManager(void); //--- configuration void Configure(const int slippage, const ulong magic); //--- basket query operations bool GetBasketInfo(const string basket_id, CBasketInfo &info); int GetAllBaskets(CBasketInfo &baskets[]); //--- basket control operations bool CloseBasket(const string basket_id); void ModifyBasketSL(const string basket_id, const double equity_stop_loss); void CheckBasketStops(void); ulong OpenBasketLeg(const string basket_id, const string symbol, ENUM_ORDER_TYPE order_type, double volume, double price, double sl, double tp); };
The static s_instance pointer and the CloseCallback() trampoline are needed because CBasketStopRegistry stores a plain function pointer rather than a method pointer. The trampoline routes the call back to the live manager instance.
GetBasketInfo() calls the scanner then attaches any registered stop threshold from the registry to the returned struct, so callers always see the current stop level and distance in one call.
//+------------------------------------------------------------------+ //| GetBasketInfo | //+------------------------------------------------------------------+ bool CBasketManager::GetBasketInfo(const string basket_id, CBasketInfo &info) { bool ok = m_scanner.GetBasketInfo(basket_id, info); if(ok) { double threshold = 0.0; if(m_registry.GetThreshold(basket_id, threshold)) { info.stop_threshold = threshold; info.distance_to_stop = info.aggregate_pnl - threshold; } } return(ok); }
CheckBasketStops() calls GetAllBaskets() to get a current snapshot, then passes it to m_registry.CheckAll(), which invokes the close callback for any breached basket. This is the only method the EA needs to call from OnTick() to keep stops active.
//+------------------------------------------------------------------+ //| CheckBasketStops | //+------------------------------------------------------------------+ void CBasketManager::CheckBasketStops(void) { CBasketInfo baskets[]; int count = GetAllBaskets(baskets); if(count > 0) m_registry.CheckAll(baskets, count); }
CloseBasket() delegates to the executor and then deregisters the basket from the stop registry, since a closed basket should not continue triggering stop checks.
//+------------------------------------------------------------------+ //| CloseBasket | //+------------------------------------------------------------------+ bool CBasketManager::CloseBasket(const string basket_id) { bool ok = m_executor.CloseBasket(basket_id); if(ok) m_registry.Deregister(basket_id); return(ok); }
ModifyBasketSL() delegates directly to m_registry.Register(). Calling it more than once for the same basket ID updates the threshold.
//+------------------------------------------------------------------+ //| ModifyBasketSL | //| Registers or updates the equity stop threshold for basket_id. | //+------------------------------------------------------------------+ void CBasketManager::ModifyBasketSL(const string basket_id, const double equity_stop_loss) { m_registry.Register(basket_id, equity_stop_loss); }
CBasketDashboard — the Chart Panel
CBasketDashboard formats the current basket state into a readable multi-line string and sets it as the chart comment via ChartSetString(). P&L values are overlaid using OBJ_LABEL graphical objects so they can be colored green or red, while all other text remains the standard black chart comment color.
//+------------------------------------------------------------------+ //| BasketDashboard.mqh | //+------------------------------------------------------------------+ #ifndef BASKETDASHBOARD_MQH #define BASKETDASHBOARD_MQH #include "BasketInfo.mqh" //+------------------------------------------------------------------+ //| CBasketDashboard | //+------------------------------------------------------------------+ class CBasketDashboard { private: string FormatPnl(const double pnl) const; string FormatVolume(const double long_vol, const double short_vol) const; string PadRight(const string s, const int width) const; string PadLeft(const string s, const int width) const; public: CBasketDashboard(void) {} ~CBasketDashboard(void) {} void Render(const CBasketInfo &baskets[], const int count) const; void Clear(void) const { ::ChartSetString(0, CHART_COMMENT, ""); } };
PadRight() and PadLeft() align columns by padding strings with trailing or leading spaces respectively.
//+------------------------------------------------------------------+ //| PadRight | //+------------------------------------------------------------------+ string CBasketDashboard::PadRight(const string s, const int width) const { string result = s; while(::StringLen(result) < width) result = result + " "; return(result); } //+------------------------------------------------------------------+ //| PadLeft | //+------------------------------------------------------------------+ string CBasketDashboard::PadLeft(const string s, const int width) const { string result = s; while(::StringLen(result) < width) result = " " + result; return(result); }
FormatPnl() prefixes positive values with + so the sign communicates direction at a glance — negative values already display their own minus sign.
//+------------------------------------------------------------------+ //| FormatPnl | //+------------------------------------------------------------------+ string CBasketDashboard::FormatPnl(const double pnl) const { string sign = (pnl >= 0.0) ? "+" : ""; return(sign + ::DoubleToString(pnl, 2)); }
FormatVolume() combines long and short volume into a single readable string such as L0.03 S0.03, prefixing each side with L or S.
//+------------------------------------------------------------------+ //| FormatVolume | //+------------------------------------------------------------------+ string CBasketDashboard::FormatVolume(const double long_vol, const double short_vol) const { string result = ""; if(long_vol > 0.0) result = "L" + ::DoubleToString(long_vol, 2); if(short_vol > 0.0) { if(::StringLen(result) > 0) result = result + " "; result = result + "S" + ::DoubleToString(short_vol, 2); } return(result); }
Render() assembles the panel into a single string (header, column headers, dividers, rows, and a timestamp). It then sets the chart comment via ChartSetString().
//+------------------------------------------------------------------+ //| Render | //+------------------------------------------------------------------+ void CBasketDashboard::Render(const CBasketInfo &baskets[], const int count) const { if(count == 0) { ::ChartSetString(0, CHART_COMMENT, "BASKET MANAGER — no active baskets"); return; } string header = "BASKET MANAGER — LIVE\n"; string divider = "------------------------------------------------------------\n"; string col_hdr = PadRight("ID", 14) + PadLeft("LEGS", 5) + PadLeft("VOLUME", 14) + PadLeft("P&L", 10) + PadLeft("STOP", 10) + PadLeft("DISTANCE", 10) + "\n"; string body = ""; for(int i = 0; i < count; i++) { string vol_str = FormatVolume(baskets[i].total_long_volume, baskets[i].total_short_volume); string pnl_str = FormatPnl(baskets[i].aggregate_pnl); string stop_str = FormatPnl(baskets[i].stop_threshold); string dist_str = FormatPnl(baskets[i].distance_to_stop); string row = PadRight(baskets[i].basket_id, 14) + PadLeft((string)baskets[i].legs, 5) + PadLeft(vol_str, 14) + PadLeft(pnl_str, 10) + PadLeft(stop_str, 10) + PadLeft(dist_str, 10); body = body + row + "\n"; } string timestamp = "\nupdated " + ::TimeToString(::TimeCurrent(), TIME_SECONDS) + " · " + (string)count + " active basket(s)"; string panel = header + divider + col_hdr + divider + body + timestamp; ::ChartSetString(0, CHART_COMMENT, panel); }
Each row concatenates six fields (ID, legs, volume, P&L, stop, and distance). The padding helpers keep columns aligned regardless of string length. The entire panel, including the P&L values, renders in the default chart comment text color; this version does not distinguish profit from loss visually. Clear() sets an empty comment string, used from OnDeinit() to leave the chart clean when the EA is removed.

Basket dashboard panel showing two active baskets with legs, volume, P&L, stop level, and distance to stop.
BasketManagerEA.mq5 — Integration Demo
The demo EA wires all components together and demonstrates the full lifecycle: opening legs across two symbols, registering stops, driving the dashboard, and triggering closure when a stop is breached.
//+------------------------------------------------------------------+ //| BasketManagerEA.mq5 | //+------------------------------------------------------------------+ #property strict #include <BasketManager/BasketManager.mqh> #include <BasketManager/BasketDashboard.mqh> //--- Input parameters input double InpUsdLongStop = -200.00; // USD_LONG basket stop (account currency) input double InpGoldHedgeStop = -150.00; // GOLD_HEDGE basket stop (account currency) input string InpGoldSymbol = "XAUUSD"; // gold symbol name on your broker input ulong InpMagic = 770099; // magic number input int InpTimerSeconds = 1; // dashboard refresh interval in seconds input bool InpDryRun = true; // true = log only, do not open real positions //--- Module-level objects CBasketManager g_manager; CBasketDashboard g_dashboard; bool g_baskets_opened = false; int g_retry_count = 0; int g_retry_limit = 5; // max ticks to wait for symbol data
InpGoldSymbol is a configurable string input that accepts the broker's exact symbol name for gold. Different brokers use different names, XAUUSD, GOLD, XAUUSDm, so making this an input avoids hardcoding a name that will fail on brokers using a different convention.
OnInit() configures the executor, registers both basket stops, and calls SymbolSelect() on the gold symbol to load it into Market Watch at startup. Subscribing the symbol in OnInit() gives the broker time to populate its properties before the first tick fires, avoiding the condition where SYMBOL_VOLUME_MIN returns 0 because the symbol data is not yet available.
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit(void) { g_manager.Configure(10, InpMagic); g_manager.ModifyBasketSL("USD_LONG", InpUsdLongStop); g_manager.ModifyBasketSL("GOLD_HEDGE", InpGoldHedgeStop); //--- subscribe the gold symbol to Market Watch immediately at startup //--- so its properties are populated by the time the first tick arrives if(!::SymbolSelect(InpGoldSymbol, true)) PrintFormat("BasketManagerEA: warning — symbol '%s' not found on this broker", InpGoldSymbol); if(!::EventSetTimer(InpTimerSeconds)) { Print("BasketManagerEA: failed to set timer"); return(INIT_FAILED); } PrintFormat("BasketManagerEA: initialized, dry_run=%s gold_symbol=%s", (InpDryRun ? "true" : "false"), InpGoldSymbol); return(INIT_SUCCEEDED); }
OnTick() fires OpenDemoBaskets() on the first tick via a retry counter, then calls CheckBasketStops() on every subsequent tick.
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick(void) { if(!g_baskets_opened) { if(g_retry_count >= g_retry_limit) { //--- only log failure once, then stop retrying if(g_retry_count == g_retry_limit) { PrintFormat("BasketManagerEA: could not open baskets after %d ticks — " "check that '%s' is available on this broker", g_retry_limit, InpGoldSymbol); g_retry_count++; } return; } g_retry_count++; OpenDemoBaskets(); } g_manager.CheckBasketStops(); }
OnTimer() calls GetAllBaskets() and Render() every second.
//+------------------------------------------------------------------+ //| Expert timer function | //+------------------------------------------------------------------+ void OnTimer(void) { CBasketInfo baskets[]; int count = g_manager.GetAllBaskets(baskets); g_dashboard.Render(baskets, count); }
OpenDemoBaskets() reads SYMBOL_VOLUME_MIN for both symbols rather than using the configured lot size directly. This makes the EA broker-agnostic: EURUSD minimum is typically 0.01 while gold minimum is often 0.10 or 1.00 depending on the broker and account type. A retry counter allows the function to silently wait up to g_retry_limit ticks for symbol data to populate before logging a single failure message and stopping.
//+------------------------------------------------------------------+ //| OpenDemoBaskets | //+------------------------------------------------------------------+ void OpenDemoBaskets(void) { string sym_fx = ::Symbol(); string sym_gold = InpGoldSymbol; double vol_fx = ::SymbolInfoDouble(sym_fx, SYMBOL_VOLUME_MIN); double vol_gold = ::SymbolInfoDouble(sym_gold, SYMBOL_VOLUME_MIN); //--- properties not yet populated — caller will retry on next tick if(vol_fx <= 0.0 || vol_gold <= 0.0) return; //--- success: mark as opened so this function never runs again g_baskets_opened = true; PrintFormat("BasketManagerEA: resolved volumes — %s min=%.2f %s min=%.2f", sym_fx, vol_fx, sym_gold, vol_gold); if(InpDryRun) { PrintFormat("BasketManagerEA [DRY RUN]: would open 3 USD_LONG BUY legs on %s vol=%.2f", sym_fx, vol_fx); PrintFormat("BasketManagerEA [DRY RUN]: would open GOLD_HEDGE BUY + SELL legs on %s vol=%.2f", sym_gold, vol_gold); return; } double ask_fx = ::SymbolInfoDouble(sym_fx, SYMBOL_ASK); double bid_fx = ::SymbolInfoDouble(sym_fx, SYMBOL_BID); double ask_gold = ::SymbolInfoDouble(sym_gold, SYMBOL_ASK); double bid_gold = ::SymbolInfoDouble(sym_gold, SYMBOL_BID); //--- USD_LONG: three BUY legs on the chart symbol g_manager.OpenBasketLeg("USD_LONG", sym_fx, ORDER_TYPE_BUY, vol_fx, ask_fx, 0.0, 0.0); g_manager.OpenBasketLeg("USD_LONG", sym_fx, ORDER_TYPE_BUY, vol_fx, ask_fx, 0.0, 0.0); g_manager.OpenBasketLeg("USD_LONG", sym_fx, ORDER_TYPE_BUY, vol_fx, ask_fx, 0.0, 0.0); //--- GOLD_HEDGE: one BUY and one SELL on the gold symbol g_manager.OpenBasketLeg("GOLD_HEDGE", sym_gold, ORDER_TYPE_BUY, vol_gold, ask_gold, 0.0, 0.0); g_manager.OpenBasketLeg("GOLD_HEDGE", sym_gold, ORDER_TYPE_SELL, vol_gold, bid_gold, 0.0, 0.0); }
Verification — TestBasketManager.mq5
The test script requires no open positions and no live broker connection. It tests the basket ID logic, P&L arithmetic, stop threshold checks, and registry mechanics with synthetic data.
//+------------------------------------------------------------------+ //| OnStart | //+------------------------------------------------------------------+ void OnStart(void) { Print("=== TestBasketManager starting ==="); TestBasketIdExtraction(); TestAggregatePnl(); TestVolumeWeightedPips(); TestStopThresholdLogic(); TestRegistryRegisterDeregister(); TestBasketInfoFields(); PrintFormat("=== TestBasketManager finished: %d/%d passed ===", g_tests_passed, g_tests_run); if(g_tests_passed == g_tests_run) Print("ALL TESTS PASSED"); else Print("SOME TESTS FAILED - see log above"); }
TestBasketIdExtraction() replicates the StringFind() + StringSubstr() logic and tests five cases: a clean basket comment, a different ID, a comment without the prefix, the edge case of "BASKET:" with no ID, and a prefix appearing mid-string.
//+------------------------------------------------------------------+ //| TestBasketIdExtraction | //| Verifies the prefix-based basket ID extraction logic directly. | //+------------------------------------------------------------------+ void TestBasketIdExtraction(void) { Print("--- Basket ID extraction tests ---"); //--- replicate the extraction logic from CBasketScanner string prefix = "BASKET:"; //--- helper lambda equivalent: extract ID from comment #define EXTRACT(comment) (::StringFind(comment, prefix) >= 0 ? \ ::StringSubstr(comment, ::StringFind(comment, prefix) + ::StringLen(prefix)) : "") string id1 = EXTRACT("BASKET:USD_LONG"); ASSERT(id1 == "USD_LONG", "extracts 'USD_LONG' from 'BASKET:USD_LONG'"); string id2 = EXTRACT("BASKET:GOLD_HEDGE"); ASSERT(id2 == "GOLD_HEDGE", "extracts 'GOLD_HEDGE' from 'BASKET:GOLD_HEDGE'"); string id3 = EXTRACT("no basket comment"); ASSERT(id3 == "", "returns empty string for comment without prefix"); string id4 = EXTRACT("BASKET:"); ASSERT(id4 == "", "returns empty string for 'BASKET:' with no ID"); string id5 = EXTRACT("entry BASKET:EUR_SHORT trailing"); ASSERT(::StringFind(id5, "EUR_SHORT") == 0, "extracts ID when prefix is not at string start"); #undef EXTRACT }
TestAggregatePnl() sums (+85.20, -12.40, +69.70) and confirms the result is 142.50. A second case sums three negative values to confirm net-negative baskets compute correctly.
//+------------------------------------------------------------------+ //| TestAggregatePnl | //| Verifies that summing three synthetic P&L values produces the | //| correct basket total. | //+------------------------------------------------------------------+ void TestAggregatePnl(void) { Print("--- Aggregate P&L tests ---"); double pnl_values[] = {85.20, -12.40, 69.70}; double total = 0.0; for(int i = 0; i < 3; i++) total += pnl_values[i]; ASSERT_DOUBLE_CLOSE(total, 142.50, 0.001, "three legs 85.20 + (-12.40) + 69.70 = 142.50"); //--- verify that a net-negative basket is also computed correctly double pnl_neg[] = {-80.00, -50.00, -35.00}; double total_neg = 0.0; for(int i = 0; i < 3; i++) total_neg += pnl_neg[i]; ASSERT_DOUBLE_CLOSE(total_neg, -165.00, 0.001, "three losing legs sum to -165.00"); }
TestVolumeWeightedPips() verifies (0.10×25 + 0.20×(−8)) / 0.30 = 3.00 and checks that equal but opposite pip values across equal volumes produce a weighted result of zero.
//+------------------------------------------------------------------+ //| TestVolumeWeightedPips | //| Verifies the volume-weighted average pip P&L formula. | //+------------------------------------------------------------------+ void TestVolumeWeightedPips(void) { Print("--- Volume-weighted pip P&L tests ---"); double vol1 = 0.10, pips1 = 25.0; double vol2 = 0.20, pips2 = -8.0; double total_vol = vol1 + vol2; double vw = (vol1 * pips1 + vol2 * pips2) / total_vol; //--- (0.10*25 + 0.20*(-8)) / 0.30 = (2.50 - 1.60) / 0.30 = 0.90 / 0.30 = 3.00 ASSERT_DOUBLE_CLOSE(vw, 3.00, 0.001, "vol-weighted: (0.10*25 + 0.20*(-8)) / 0.30 = 3.00 pips"); ASSERT(vw > 0.0, "vol-weighted result is positive (long leg dominates by volume)"); //--- equal volumes: average should be the arithmetic mean double vw_eq = (0.10 * 10.0 + 0.10 * (-10.0)) / 0.20; ASSERT_DOUBLE_CLOSE(vw_eq, 0.0, 0.001, "equal volumes, equal but opposite pips -> 0.0 weighted result"); }
TestStopThresholdLogic() applies the strict less-than breach check to four cases: -165.00 breaches -150.00; -80.00 does not; exactly -150.00 does not (strict less-than); and a positive P&L never breaches any negative threshold.
//+------------------------------------------------------------------+ //| TestStopThresholdLogic | //| Verifies the breached() check: aggregate P&L < threshold. | //+------------------------------------------------------------------+ void TestStopThresholdLogic(void) { Print("--- Stop threshold logic tests ---"); double threshold = -150.00; double pnl_breach = -165.00; double pnl_safe = -80.00; double pnl_exactly = -150.00; ASSERT(pnl_breach < threshold, "P&L -165.00 < threshold -150.00 -> breached"); ASSERT(!(pnl_safe < threshold), "P&L -80.00 not < threshold -150.00 -> not breached"); //--- strictly less than: exactly at threshold does not trigger ASSERT(!(pnl_exactly < threshold), "P&L exactly at threshold (-150.00) -> not breached (strict <)"); //--- positive P&L is never a breach ASSERT(!(50.00 < threshold), "positive P&L is never a breach"); }
TestRegistryRegisterDeregister() exercises the full stop registry lifecycle including registration, retrieval, re-registration updating the value, deregistration, and attempting to deregister a non-existent basket.
//+------------------------------------------------------------------+ //| TestRegistryRegisterDeregister | //| Confirms that the stop registry stores, retrieves, and removes | //| thresholds correctly. | //+------------------------------------------------------------------+ void TestRegistryRegisterDeregister(void) { Print("--- CBasketStopRegistry register/deregister tests ---"); CBasketStopRegistry registry; ASSERT(registry.Count() == 0, "registry count is 0 before registration"); registry.Register("USD_LONG", -200.00); registry.Register("GOLD_HEDGE", -150.00); ASSERT(registry.Count() == 2, "registry count is 2 after two registrations"); double t1 = 0.0; bool ok1 = registry.GetThreshold("USD_LONG", t1); ASSERT(ok1, "GetThreshold returns true for registered basket"); ASSERT_DOUBLE_CLOSE(t1, -200.00, 0.001, "USD_LONG threshold is -200.00"); //--- re-registering updates the threshold registry.Register("USD_LONG", -180.00); double t1b = 0.0; registry.GetThreshold("USD_LONG", t1b); ASSERT_DOUBLE_CLOSE(t1b, -180.00, 0.001, "re-registration updates threshold to -180.00"); ASSERT(registry.Count() == 2, "count unchanged after re-registration"); //--- deregister bool dereg = registry.Deregister("GOLD_HEDGE"); ASSERT(dereg, "Deregister() returns true for existing basket"); double t2 = 0.0; bool ok2 = registry.GetThreshold("GOLD_HEDGE", t2); ASSERT(!ok2, "GetThreshold returns false after deregistration"); //--- deregistering non-existent basket bool dereg_miss = registry.Deregister("NONEXISTENT"); ASSERT(!dereg_miss, "Deregister() returns false for non-existent basket"); }
TestBasketInfoFields() constructs two CBasketInfo structs manually and verifies that distance_to_stop = aggregate_pnl − stop_threshold computes correctly for both a profitable basket (142.50 − (−200.00) = 342.50) and a losing one (−38.20 − (−150.00) = 111.80).
Extending the Manager
A basket take-profit alongside the stop: CBasketStopRegistry currently checks only a lower bound on P&L. Adding an upper bound — close the basket when aggregate P&L exceeds +$300.00 — requires a second threshold field in SStopEntry and a second comparison in CheckAll(). The callback and close path are unchanged.
Scaling basket volume up or down: A ScaleBasket() method on CBasketManager would iterate all legs and partially close or add volume proportionally. Because MetaTrader 5 does not support direct volume modification, this requires a sequence of OrderSend() calls — partial closes for oversized legs or additional opens for undersized ones.
Persisting basket stop levels across restarts: ModifyBasketSL() can write each threshold to a global variable via GlobalVariableSet() in OnDeinit() and restore it via GlobalVariableGet() in OnInit(), using a consistent naming convention such as "BASKET_STOP_USD_LONG".
Limitations
Basket closure is sequential, not simultaneous: CloseBasket() closes legs one at a time in a loop. During fast markets the first and last closures may execute at different prices, making final realized P&L different from the aggregate floating P&L at the moment the close was triggered.
The comment field can be overwritten by the broker: Some brokers append confirmation text to the comment field after an order is accepted. When this happens the basket prefix is lost and the position falls out of the basket on the next scan. Testing whether comments survive on a specific broker before deploying is essential.
P&L aggregation does not include swap: POSITION_PROFIT excludes swap accrued on positions held overnight. Adding POSITION_SWAP to the aggregate sum in GetBasketInfo() resolves this for multi-session positions.
CheckBasketStops() requires the caller to drive it: There is no background thread in MQL5. The EA must call CheckBasketStops() from OnTick() or OnTimer() for stops to be evaluated. During low-activity sessions ticks can be infrequent and stop checks may lag.
The gold symbol name is broker-dependent: InpGoldSymbol defaults to "XAUUSD" but brokers name this instrument differently — GOLD, XAUUSDm, XAUUSD. and others are all in use. If SymbolSelect() returns false at startup, the configured name does not exist on that broker and must be corrected in the EA inputs before live use.
Conclusion
CBasketManager groups positions by a comment prefix and provides aggregate basket operations, floating P&L and volume-weighted pip calculations, coordinated closure, unified equity stops, and a live dashboard. MetaTrader 5 does not provide these capabilities as a single native abstraction. The basket ID remains visible in the Trades tab and recoverable after terminal restarts without additional global state, while the executor resolves broker-supported filling modes dynamically to avoid errors caused by hardcoded filling policies.
The limitations are equally important. Basket closure is sequential rather than truly simultaneous, so realized P&L may differ during fast market moves. Basket identification also depends on position comments remaining intact; brokers that alter comment text may require a more robust identification mechanism. The current implementation does not provide portfolio-level risk aggregation across multiple baskets, and stop checks must be triggered by the EA through OnTick() or OnTimer() .
These limitations fit naturally into the existing architecture. Features such as basket take-profit thresholds, proportional scaling, persistent stop levels, stronger basket identification, or portfolio-wide risk limits can be added without changing the fundamental separation between basket state, execution, risk control, and presentation.
Programs used in the article:
| # | Name | Type | Description |
|---|---|---|---|
| 1 | BasketInfo.mqh | Include File | CBasketInfo struct holding the full aggregate state snapshot for one basket |
| 2 | BasketScanner.mqh | Include File | CBasketScanner — reads positions, extracts basket IDs, populates CBasketInfo structs |
| 3 | BasketExecutor.mqh | Include File | CBasketExecutor — closes baskets leg by leg and opens new legs with the basket comment prepended; resolves filling mode dynamically |
| 4 | BasketStopRegistry.mqh | Include File | CBasketStopRegistry — stores per-basket equity stops and triggers a callback when a threshold is breached |
| 5 | BasketManager.mqh | Include File | CBasketManager — the public interface coordinating all three sub-components |
| 6 | BasketDashboard.mqh | Include File | CBasketDashboard — renders the live basket state as a chart comment with colored P&L overlays |
| 7 | BasketManagerEA.mq5 | Demo EA | Demo EA opening legs on EURUSD and a configurable gold symbol, registering stops, and driving the live dashboard |
| 8 | TestBasketManager.mq5 | Script | Verification script with synthetic data tests across six test functions |
| 9 | BasketManager.zip | Zip Archive | Zip archive containing all the attached files and their paths relative to the terminal's root folder. |
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.
Features of Custom Indicators Creation
Foundation Models for Trading (Part II): Decoding, Autoregression, and an Exact KV-Cache
Features of Experts Advisors
Making Custom Indicators for Beginners (Part 1): SuperTrend Indicator
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use