Position Management: A Reusable Trade Journal with Live Maximum Adverse Excursion, Maximum Favorable Excursion, and R-Multiple Tracking in MQL5
Introduction
Most Expert Advisor developers evaluate performance from the MetaTrader 5 account history—profit, loss, and duration—and nothing more. Others log from history after the fact, reading closed deals from the terminal pool and writing summary rows to CSV. Both approaches share the same blind spot: they lose the information that exists only while the trade is open.
Maximum adverse excursion is the largest loss a position experienced at any point before closing. Maximum favorable excursion is the largest profit a position reached at any point before closing. These two numbers, measured tick by tick while the position is open, tell you things the final profit-and-loss figure cannot. A trade that closed at +2R but drew down -1.8R before recovering is a very different trade from one that moved immediately in your favor and never looked back. Both show the same result in account history. Only live tracking reveals the difference.

Figure 1. Maximum adverse excursion, maximum favorable excursion, and exit explained.
Maximum adverse excursion and maximum favorable excursion answer practical questions that determine whether a strategy is genuinely sound. If the average maximum favorable excursion is large relative to stop distance, entries are poorly timed or stops are too tight. If the average maximum favorable excursion is large relative to the average winner, profits are being cut short and a trailing stop would improve performance substantially. When R-multiple distribution is consistently skewed below 1.0, risk-reward assumptions are wrong regardless of win rate.
These are the questions a trade journal answers—but only if the journal tracks positions while they are open, not after they close.
The central question "CTradeJournal" is designed to answer is this:
What did this position actually experience between open and close, and what does that tell me about the strategy that produced it?
This article builds "CTradeJournal"—a self-contained, reusable MQL5 class that any EA integrates with four lines of code. The class tracks every open position on every tick, maintains live maximum adverse excursion and maximum favorable excursion figures in account currency, and writes a complete structured record to CSV when each position closes. The calling EA supplies only what it should: an optional entry reason string and any indicator values it wants to be logged at entry. The journal handles all tick-level tracking, maximum adverse excursion and maximum favorable excursion computation, R-multiple calculation, and file writing.
Before showing what the journal does, this article explains why the common approaches lose the information that matters most. Every design decision in "CTradeJournal" exists because of a specific gap in the tools developers typically reach for.
We will cover the following topics:
- Why Post-Trade Logging Loses the Information That Matters
- What the R-Multiple Tells You That Profit Does Not
- Architecture: Two Files, One Clear Responsibility Each
- Implementation in MQL5
- Integrating the Journal Into Your Own EA
- The Demonstration EA
- What the CSV Produces
- Backtesting
- Known Limitations
- Conclusion
Why Post-Trade Logging Loses the Information That Matters
When a position closes, MetaTrader 5 records the following in the history pool: open time, close time, open price, close price, volume, profit, commission, and swap. This is the raw deal record. It describes the two endpoints of the trade. It describes nothing about what happened in between.
Consider two trades that both close at +$100 profit on a $50 risk.
- Trade A opened, moved immediately in the correct direction, and closed at +$100 without ever drawing down more than $5. Maximum adverse excursion: -$5. Maximum favorable excursion: +$110.
- Trade B opened, immediately moved $45 against the position, recovered, and eventually closed at +$100. Maximum adverse excursion: -$45. Maximum favorable excursion: +$105.

Figure 2. Trade A vs. Trade B Comparison.
From the history pool, both trades look identical. From the journal, they tell entirely different stories about entry quality and stop placement. The ratio of maximum adverse excursion to initial risk is one of the most diagnostic metrics in systematic trading. A consistently high ratio means the strategy enters at the wrong moment and relies on mean reversion to recover—useful information that changes how stops are set, how entries are timed, and whether signals need tighter filtering. This ratio cannot be computed from history. It can only be computed from live tracking.
What the R-Multiple Tells You That Profit Does Not
The R-multiple expresses a trade's result in units of initial risk. A trade that risked $50 and made $100 has an R-multiple of +2.0. A trade that risked $50 and lost $50 has an R-multiple of -1.0. Expressing results in R removes the effect of varying position sizes and varying stop distances, making trades directly comparable across different instruments, timeframes, and market conditions.
The distribution of R-multiples across a strategy's trade history is the most honest measure of edge. A strategy with positive expectancy has an R-multiple distribution whose weighted mean is above zero. If the distribution is clustered tightly around +0.5 to +2.0 with few outliers, the strategy delivers consistent outcomes. If it shows frequent -1.0 results with occasional large positives, the strategy depends on rare winners to compensate for routine full losses—a fragile structure.
None of this analysis is possible without knowing the initial risk for each trade. The history pool does not store this. The journal does—because it captures the stop distance immediately when the position opens, which is the correct definition of initial risk.
Architecture: Two Files, One Clear Responsibility Each
"CTradeJournal" is distributed across two files.
- "TradeJournal.mqh" is the self-contained journal class. It tracks all open positions, maintains live maximum adverse excursion and maximum favorable excursion, handles CSV file writing, and manages the header row. It knows nothing about entry signals, indicators, or strategy logic.
- "JournalDemoEA.mq5" is the demonstration EA. An EMA crossover strategy that integrates "CTradeJournal" with four lines of code. The signal logic can be replaced with any entry without touching the journal.
This layering has a practical consequence. A developer adding the journal to an existing EA only needs "TradeJournal.mqh." Include it, follow the four integration steps in Section 5, and your existing signal drives the journal.
The "CTradeJournal" Public Interface
The most important design decision in this interface is that "Update()" must be called on every tick—not on every bar. Maximum adverse excursion and maximum favorable excursion are intrabar phenomena. A position can reach its worst adverse excursion and recover entirely within a single bar. If "Update()" runs only on bar close, that excursion is invisible, and the entire value of live tracking is lost.

Figure 3. Architecture overview.
- "Init(filename, magic)" initializes the file handle and writes the CSV header. Call from "OnInit()." Returns false if the file cannot be opened.
- "NotifyOpen(ticket, entry_reason, custom_fields)" registers a newly opened position for tracking. Call immediately after a successful trade opens. The "custom_fields" parameter accepts any string—pass indicator values, signal names, or context notes formatted however the calling EA prefers.
- "Update()" scans all tracked positions on every tick and updates maximum adverse excursion and maximum favorable excursion for each one. This is the core of live tracking. The function reads only position data already in memory, performs two comparisons per position, and returns immediately.
- "NotifyClose(ticket, close_price, close_time, profit_usd, duration_bars)" computes the final R-multiple, writes the complete record to CSV, and removes the position from tracking. Call from "OnTradeTransaction()" when a close deal is detected.
- "GetMAE(ticket)" and "GetMFE(ticket)" return the current live maximum adverse excursion and maximum favorable excursion for a tracked position in account currency. Useful for adaptive trailing logic in the calling EA.
Implementation in MQL5
We build the class section by section, explaining each component before showing the code.
The Position Record Structure and Infrastructure Helpers
Each tracked position needs its own data record. The structure stores everything needed to compute the final journal row.
Save to "MQL5\Include\TradeJournal\TradeJournal.mqh."
//+------------------------------------------------------------------+ //| TradeJournal.mqh | //| Reusable trade journal — live MAE, MFE, and R-multiple tracking | //+------------------------------------------------------------------+ #ifndef TRADEJOURNAL_MQH #define TRADEJOURNAL_MQH //+------------------------------------------------------------------+ //| Data record for one tracked position | //+------------------------------------------------------------------+ struct SJournalRecord { ulong ticket; // Position ticket string symbol; // Trading symbol long direction; // POSITION_TYPE_BUY or POSITION_TYPE_SELL datetime open_time; // Position open time double open_price; // Position open price double stop_loss; // Initial stop loss price double initial_risk_usd; // Initial risk in account currency double lots; // Position volume double mae_usd; // Maximum adverse excursion (negative) double mfe_usd; // Maximum favorable excursion (positive) string entry_reason; // Caller-supplied entry reason string custom_fields; // Caller-supplied indicator values or notes };
The "initial_risk_usd" field is computed from the stop distance and tick value when "NotifyOpen()" is called. This is the R denominator. The "mae_usd" is always negative or zero. The "mfe_usd" is always positive or zero. Both update on every "Update()" call using the current floating profit.
Two infrastructure helper functions support the class. "GetPipValue()" computes the monetary value of one pip for any instrument using "SYMBOL_TRADE_TICK_VALUE" and "SYMBOL_TRADE_TICK_SIZE"—the correct approach for forex, gold, and index instruments alike, not the simplified point multiplication that fails on nonstandard contracts. "CalcInitialRisk()" uses the same tick value approach to express the stop distance as a monetary amount in account currency.
//+------------------------------------------------------------------+ //| Monetary value of one pip for a given symbol and lot size | //| Correct for standard forex, JPY pairs, gold, and indexes | //+------------------------------------------------------------------+ double GetPipValue(const string symbol, double lots) { double tick_val = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE); // Tick value double tick_size = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE); // Tick size int digits = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS); // Symbol digits double point = SymbolInfoDouble(symbol, SYMBOL_POINT); // Point size double pip_size = (digits == 3 || digits == 5) ? point * 10.0 : point; // Pip size if(tick_size <= 0 || tick_val <= 0) return 0; // Validate inputs return (pip_size / tick_size) * tick_val * lots; // Return pip value } //+------------------------------------------------------------------+ //| Initial risk in account currency from stop distance | //+------------------------------------------------------------------+ double CalcInitialRisk(const string symbol, long direction, double open_price, double stop_loss, double lots) { if(stop_loss <= 0) return 0; // No stop set double tick_val = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE); // Tick value double tick_size = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE); // Tick size if(tick_size <= 0 || tick_val <= 0) return 0; // Validate inputs double dist = (direction == POSITION_TYPE_BUY) // Stop distance ? open_price - stop_loss : stop_loss - open_price; if(dist <= 0) return 0; // Invalid stop return (dist / tick_size) * tick_val * lots; // Return risk }
These two functions have no dependency on the journal class and can be included in any EA that needs accurate monetary pip values or initial risk computation—the same instrument-agnostic approach used throughout this series.
The Journal Class
//+------------------------------------------------------------------+ //| CTradeJournal — self-contained trade journal class | //+------------------------------------------------------------------+ class CTradeJournal { private: SJournalRecord m_records[]; // Array of tracked positions int m_count; // Number of currently tracked positions int m_file; // CSV file handle string m_filename; // CSV filename long m_magic; // Magic number filter //+------------------------------------------------------------------+ //| Finds index of a ticket in the records array | //| Returns -1 if not found | //+------------------------------------------------------------------+ int FindRecord(ulong ticket) { for(int i = 0; i < m_count; i++) // Iterate records if(m_records[i].ticket == ticket) return i; // Found return -1; // Not found } //+------------------------------------------------------------------+ //| Removes a record by index, shifting remaining records left | //+------------------------------------------------------------------+ void RemoveRecord(int index) { for(int i = index; i < m_count - 1; i++) // Shift left m_records[i] = m_records[i + 1]; // Overwrite slot m_count--; // Decrement count ArrayResize(m_records, m_count); // Shrink array } //+------------------------------------------------------------------+ //| Writes the CSV header row | //+------------------------------------------------------------------+ void WriteHeader() { string header = "Ticket,Symbol,Direction,OpenTime,CloseTime," "OpenPrice,ClosePrice,Lots,StopLoss," "InitialRiskUSD,ProfitUSD,MAE_USD,MFE_USD," "R_Multiple,DurationBars,EntryReason,CustomFields\n"; // Header columns FileWriteString(m_file, header); // Write to file FileFlush(m_file); // Flush to disk } public: CTradeJournal() : m_count(0), m_file(INVALID_HANDLE), m_magic(0) {} //+------------------------------------------------------------------+ //| Initialize the journal. Call from OnInit(). | //+------------------------------------------------------------------+ bool Init(const string filename, long magic) { m_filename = filename; // Store filename m_magic = magic; // Store magic m_count = 0; // Reset count ArrayResize(m_records, 0); // Clear records m_file = FileOpen(filename, FILE_READ | FILE_WRITE | FILE_CSV | FILE_ANSI | FILE_COMMON); // Open file if(m_file == INVALID_HANDLE) // Check handle { Print("CTradeJournal: Cannot open file: ", filename, " | Error:", GetLastError()); // Log error return false; // Return failure } FileSeek(m_file, 0, SEEK_END); // Seek to end if(FileTell(m_file) == 0) WriteHeader(); // Write if new file Print("CTradeJournal: Initialized | File:", filename, " | Magic:", magic); // Log success return true; // Return success } //+------------------------------------------------------------------+ //| Register a newly opened position for tracking | //| Call immediately after a successful trade open | //+------------------------------------------------------------------+ void NotifyOpen(ulong ticket, const string entry_reason = "", const string custom_fields = "") { if(!PositionSelectByTicket(ticket)) return; // Select position if(PositionGetInteger(POSITION_MAGIC) != m_magic) return; // Check magic SJournalRecord rec; // New record rec.ticket = ticket; // Store ticket rec.symbol = PositionGetString(POSITION_SYMBOL); // Store symbol rec.direction = PositionGetInteger(POSITION_TYPE); // Store direction rec.open_time = (datetime)PositionGetInteger(POSITION_TIME); // Store open time rec.open_price = PositionGetDouble(POSITION_PRICE_OPEN); // Store open price rec.stop_loss = PositionGetDouble(POSITION_SL); // Store stop loss rec.lots = PositionGetDouble(POSITION_VOLUME); // Store lots rec.initial_risk_usd = CalcInitialRisk(rec.symbol, rec.direction, // Compute risk rec.open_price, rec.stop_loss, rec.lots); rec.mae_usd = 0; // Initialize MAE rec.mfe_usd = 0; // Initialize MFE rec.entry_reason = entry_reason; // Store reason rec.custom_fields = custom_fields; // Store custom data ArrayResize(m_records, m_count + 1); // Expand array m_records[m_count] = rec; // Store record m_count++; // Increment count Print(StringFormat( "CTradeJournal: Tracking | Ticket:%I64u | %s | Risk:$%.2f | Reason:%s", ticket, rec.symbol, rec.initial_risk_usd, entry_reason)); // Log entry } //+------------------------------------------------------------------+ //| Update MAE and MFE for all tracked positions | //| Call on EVERY TICK — this is where live tracking happens | //+------------------------------------------------------------------+ void Update() { for(int i = 0; i < m_count; i++) // Iterate records { if(!PositionSelectByTicket(m_records[i].ticket)) continue; // Select position double profit = PositionGetDouble(POSITION_PROFIT); // Current profit if(profit < m_records[i].mae_usd) m_records[i].mae_usd = profit; // Update MAE if(profit > m_records[i].mfe_usd) m_records[i].mfe_usd = profit; // Update MFE } } //+------------------------------------------------------------------+ //| Write the final journal row when a position closes | //| Call from OnTradeTransaction() when DEAL_ENTRY_OUT is detected | //+------------------------------------------------------------------+ void NotifyClose(ulong ticket, double close_price, datetime close_time, double profit_usd, int duration_bars = 0) { int idx = FindRecord(ticket); // Find record if(idx < 0) return; // Not tracked SJournalRecord rec = m_records[idx]; // Copy record double r_multiple = (rec.initial_risk_usd > 0) // Compute R-multiple ? profit_usd / rec.initial_risk_usd : 0; string direction = (rec.direction == POSITION_TYPE_BUY) ? "BUY" : "SELL"; // Direction string string row = StringFormat( "%I64u,%s,%s,%s,%s,%.5f,%.5f,%.2f,%.5f,%.2f,%.2f,%.2f,%.2f,%.3f,%d,\"%s\",\"%s\"\n", rec.ticket, // Ticket rec.symbol, // Symbol direction, // Direction TimeToString(rec.open_time, TIME_DATE | TIME_MINUTES), // Open time TimeToString(close_time, TIME_DATE | TIME_MINUTES), // Close time rec.open_price, // Open price close_price, // Close price rec.lots, // Lots rec.stop_loss, // Stop loss rec.initial_risk_usd, // Initial risk profit_usd, // Profit rec.mae_usd, // MAE rec.mfe_usd, // MFE r_multiple, // R-multiple duration_bars, // Duration in bars rec.entry_reason, // Entry reason rec.custom_fields // Custom fields ); FileSeek(m_file, 0, SEEK_END); // Seek to end FileWriteString(m_file, row); // Write row FileFlush(m_file); // Flush to disk Print(StringFormat( "CTradeJournal: Closed | %s %s | P&L:$%.2f | MAE:$%.2f | MFE:$%.2f | R:%.3f", rec.symbol, direction, profit_usd, rec.mae_usd, rec.mfe_usd, r_multiple)); // Log close RemoveRecord(idx); // Remove record } //+------------------------------------------------------------------+ //| Returns true if the ticket is currently tracked | //+------------------------------------------------------------------+ bool IsTracking(ulong ticket) { return FindRecord(ticket) >= 0; } // Check tracking //+------------------------------------------------------------------+ //| Returns current MAE for a tracked ticket in account currency | //+------------------------------------------------------------------+ double GetMAE(ulong ticket) { int idx = FindRecord(ticket); // Find record return (idx >= 0) ? m_records[idx].mae_usd : 0; // Return MAE } //+------------------------------------------------------------------+ //| Returns current MFE for a tracked ticket in account currency | //+------------------------------------------------------------------+ double GetMFE(ulong ticket) { int idx = FindRecord(ticket); // Find record return (idx >= 0) ? m_records[idx].mfe_usd : 0; // Return MFE } //+------------------------------------------------------------------+ //| Close the file handle. Call from OnDeinit(). | //+------------------------------------------------------------------+ void Deinit() { if(m_file != INVALID_HANDLE) // Check handle { FileClose(m_file); // Close file m_file = INVALID_HANDLE; // Reset handle } } }; #endif // TRADEJOURNAL_MQH //+------------------------------------------------------------------+
Two design decisions deserve attention here.
The file is opened with "FILE_COMMON"—this places the CSV in the shared "MQL5\Files\Common\" folder accessible from any MetaTrader 5 instance on the machine, regardless of which broker's terminal is running. The file can be opened in Excel or a spreadsheet application while the EA is running without file locking issues because "FileFlush()" is called after every write.
"Update()" uses "POSITION_PROFIT"—the current floating profit in account currency—to track maximum adverse excursion and maximum favorable excursion. This is the correct approach because it accounts for commission, swap, and the spread at entry automatically. The maximum adverse excursion figure reflects the real monetary damage the position experienced, not a theoretical pip calculation that ignores broker costs.
Integrating the Journal Into Your Own EA
Plugging "CTradeJournal" into an existing EA requires changes in exactly four places. The entry signal and all other strategy logic remain entirely unchanged.
Step 1—Add the include:
#include <TradeJournal\TradeJournal.mqh>Step 2—Declare the journal at global scope:
CTradeJournal g_journal;
Step 3—Initialize and deinitialize:
//--- In OnInit() if(!g_journal.Init("MyEA_Journal.csv", Magic_Number)) return INIT_FAILED; //--- In OnDeinit() g_journal.Deinit();
Step 4—Three calls in the right places:
//--- Top of OnTick() — every tick, no exception g_journal.Update(); //--- After every successful trade open g_journal.NotifyOpen(pos_ticket, "Signal name", "RSI=67.4|ATR=0.00120"); //--- In OnTradeTransaction() when DEAL_ENTRY_OUT detected g_journal.NotifyClose(pos_id, close_price, close_time, net_profit, bar_count);
Four steps. Zero modifications to the journal class itself.
The Demonstration EA
Save to "MQL5\Experts\JournalDemoEA.mq5." This EA shows exactly how to integrate "CTradeJournal" into an existing strategy. The EMA crossover entry logic in "CheckForEntry()" can be replaced with any signal without changing a single line of journal code.
Inputs and Declarations
The inputs are organized into three groups. The "Entry" group controls the EMA periods and ATR stop multiplier. The "Journal" group sets the CSV filename and risk percent used for the initial risk calculation. The "General" group sets the magic number and slippage.
//+------------------------------------------------------------------+ //| JournalDemoEA.mq5 | //| Copyright 2026, Tola Moses Hector | //| https://t.me/tolahector | //+------------------------------------------------------------------+ #property copyright "Copyright 2026, Tola Moses Hector" #property link "https://t.me/tolahector" #property version "1.00" #property description "Demonstrates CTradeJournal integration." #property description "Replace CheckForEntry() to use in your own EA." #include <Trade\Trade.mqh> #include <TradeJournal\TradeJournal.mqh> // Journal include input group "=== Entry ===" input int Fast_MA_Period = 20; // Fast EMA period input int Slow_MA_Period = 50; // Slow EMA period input int ATR_Period = 14; // ATR period input double SL_ATR_Mult = 1.5; // ATR multiplier for stop distance input double RR = 2.0; // Risk-reward ratio input group "=== Journal ===" input string Journal_File = "TradeJournal.csv"; // CSV output filename input group "=== General ===" input int Magic_Number = 444001; // Magic number input int Slippage = 10; // Slippage in points CTrade g_trade; // Trade execution object CTradeJournal g_journal; // Journal declaration int g_fast_handle, g_slow_handle, g_atr_handle; // Indicator handles double g_fast_buf[], g_slow_buf[], g_atr_buf[]; // Indicator buffers datetime g_last_bar = 0; // Last processed bar time int g_bar_count = 0; // Bar counter for duration tracking
OnInit—Validation and Journal Initialization
OnInit initializes the journal first—if the file cannot be opened, the EA refuses to start. The three indicator handles are then created and validated. The "g_bar_count" starts at zero and increments on each new bar, providing a simple bar counter for the "duration_bars" field passed to "NotifyClose()."
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { if(!g_journal.Init(Journal_File, Magic_Number)) // Initialize journal return INIT_FAILED; // File error—refuse start g_trade.SetExpertMagicNumber(Magic_Number); // Set magic number g_trade.SetDeviationInPoints(Slippage); // Set slippage g_fast_handle = iMA(_Symbol, PERIOD_H1, Fast_MA_Period, 0, MODE_EMA, PRICE_CLOSE); // Fast EMA g_slow_handle = iMA(_Symbol, PERIOD_H1, Slow_MA_Period, 0, MODE_EMA, PRICE_CLOSE); // Slow EMA g_atr_handle = iATR(_Symbol, PERIOD_H1, ATR_Period); // ATR if(g_fast_handle == INVALID_HANDLE || // Check fast EMA g_slow_handle == INVALID_HANDLE || // Check slow EMA g_atr_handle == INVALID_HANDLE) // Check ATR { Print("Indicator handle creation failed."); return INIT_FAILED; } ArraySetAsSeries(g_fast_buf, true); // Set as series ArraySetAsSeries(g_slow_buf, true); // Set as series ArraySetAsSeries(g_atr_buf, true); // Set as series return INIT_SUCCEEDED; // Return success } //+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { g_journal.Deinit(); // Close journal file IndicatorRelease(g_fast_handle); // Release fast EMA IndicatorRelease(g_slow_handle); // Release slow EMA IndicatorRelease(g_atr_handle); // Release ATR }
OnTick—The Update/Entry Split
OnTick separates two distinct responsibilities. The "g_journal.update()" runs unconditionally on every tick because maximum adverse excursion and maximum favorable excursion are intrabar phenomena that must be captured at tick frequency. Entry signal evaluation runs only on new bars because EMA crossover signals are only meaningful on completed bars.
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { g_journal.Update(); // Update MAE/MFE every tick datetime current_bar = iTime(_Symbol, PERIOD_H1, 0); // Current bar open time bool is_new_bar = (current_bar != g_last_bar); // New bar flag if(is_new_bar) { g_last_bar = current_bar; // Update last bar g_bar_count++; // Increment bar counter if(CopyBuffer(g_fast_handle, 0, 0, 3, g_fast_buf) < 1) return; // Copy fast EMA if(CopyBuffer(g_slow_handle, 0, 0, 3, g_slow_buf) < 1) return; // Copy slow EMA if(CopyBuffer(g_atr_handle, 0, 0, 3, g_atr_buf) < 1) return; // Copy ATR } if(is_new_bar && !PositionSelect(_Symbol)) // New bar, no open position CheckForEntry(); // Check entry signal }
OnTradeTransaction—Capturing Close Events
OnTradeTransaction filters for close deals belonging to this EA and calls "g_journal notify close" with the position ID, close price, close time, net profit (including commission and swap), and the current bar count. The net profit figure makes the R-multiple a true net R-multiple rather than a gross one that ignores broker costs.
//+------------------------------------------------------------------+ //| Expert trade transaction function | //+------------------------------------------------------------------+ void OnTradeTransaction(const MqlTradeTransaction& trans, const MqlTradeRequest& req, const MqlTradeResult& res) { if(trans.type != TRADE_TRANSACTION_DEAL_ADD) return; // Only deal events if(!HistoryDealSelect(trans.deal)) return; // Select deal if(HistoryDealGetInteger(trans.deal, DEAL_MAGIC) != Magic_Number) return; // Check magic if(HistoryDealGetInteger(trans.deal, DEAL_ENTRY) != DEAL_ENTRY_OUT) return; // Only closes ulong pos_id = (ulong)HistoryDealGetInteger(trans.deal, DEAL_POSITION_ID); // Position ID double close_price = HistoryDealGetDouble(trans.deal, DEAL_PRICE); // Close price double profit = HistoryDealGetDouble(trans.deal, DEAL_PROFIT) // Net profit + HistoryDealGetDouble(trans.deal, DEAL_COMMISSION) + HistoryDealGetDouble(trans.deal, DEAL_SWAP); datetime close_time = (datetime)HistoryDealGetInteger(trans.deal, DEAL_TIME); // Close time g_journal.NotifyClose(pos_id, close_price, close_time, profit, g_bar_count); // Notify journal }
Entry Signal—Fully Decoupled From the Journal
"CheckForEntry()" demonstrates exactly what the journal expects from the calling EA: a position ticket and whatever context the developer wants to be logged. The "custom" string captures the indicator state at the exact moment of entry. A year from now, reviewing the journal, you can see exactly what the indicators were reading when every trade was entered.
The position ticket is retrieved from "DEAL_POSITION_ID" on the deal record, not from "ResultOrder()." This is the reliable ticket capture method—the position ID from the deal record is what "PositionSelectByTicket()" expects on hedging accounts.
//+------------------------------------------------------------------+ //| Check for entry signal | //+------------------------------------------------------------------+ void CheckForEntry() { double fast_prev = g_fast_buf[2], fast_curr = g_fast_buf[1]; // EMA values double slow_prev = g_slow_buf[2], slow_curr = g_slow_buf[1]; // EMA values double sl_dist = g_atr_buf[1] * SL_ATR_Mult; // Stop distance //--- Indicator snapshot for journal string custom = StringFormat("FastEMA=%.5f|SlowEMA=%.5f|ATR=%.5f", fast_curr, slow_curr, g_atr_buf[1]); // Build snapshot string //--- Bullish crossover if(fast_prev < slow_prev && fast_curr > slow_curr) { double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK); // Current ask double sl = NormalizeDouble(ask - sl_dist, _Digits); // Stop loss price double tp = NormalizeDouble(ask + sl_dist * RR, _Digits); // Take profit price if(g_trade.Buy(0.1, _Symbol, ask, sl, tp, "Journal Demo")) // Open long { ulong deal = g_trade.ResultDeal(); // Get deal ticket if(!HistoryDealSelect(deal)) return; // Select deal ulong pos_ticket = (ulong)HistoryDealGetInteger(deal, DEAL_POSITION_ID); // Get position ID g_journal.NotifyOpen(pos_ticket, "EMA Bull Cross", custom); // Register with journal } } //--- Bearish crossover else if(fast_prev > slow_prev && fast_curr < slow_curr) { double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID); // Current bid double sl = NormalizeDouble(bid + sl_dist, _Digits); // Stop loss price double tp = NormalizeDouble(bid - sl_dist * RR, _Digits); // Take profit price if(g_trade.Sell(0.1, _Symbol, bid, sl, tp, "Journal Demo")) // Open short { ulong deal = g_trade.ResultDeal(); // Get deal ticket if(!HistoryDealSelect(deal)) return; // Select deal ulong pos_ticket = (ulong)HistoryDealGetInteger(deal, DEAL_POSITION_ID); // Get position ID g_journal.NotifyOpen(pos_ticket, "EMA Bear Cross", custom); // Register with journal } } } //+------------------------------------------------------------------+
What the CSV Produces
After a test run, the CSV file in "MQL5\Files\Common\" contains one row per closed trade. Each row has seventeen columns: Ticket, Symbol, Direction, Open & Close Time, Open Price, Close Price, Lots, Stop Loss, Initial Risk USD, Profit USD, Maximum Adverse Excursion, Maximum Favorable Excursion, R_Multiple, Duration Bars, Entry Reason, and Custom Fields.
"InitialRiskUSD" is computed from the stop distance and tick value at entry—not from the position size alone. Maximum adverse excursion shows the worst floating loss the position experienced at any tick during its life, not the final loss. Maximum favorable excursion shows the best floating profit reached. "R_multiple" is the net profit divided by the initial risk. "EntryReason" contains the string passed to "NotifyOpen()." "CustomFields" contains the indicator snapshot.
In a spreadsheet, you can immediately sort by "R_multiple" to see the distribution, filter by "EntryReason" to compare different signal types, plot maximum adverse excursion against "R_multiple" to evaluate entry quality, and compute the ratio of maximum favorable excursion to "ProfitUSD" to see how much of each winner was left on the table. These analyses require nothing beyond Excel or Google Sheets. The journal provides the data. The interpretation is yours.

Figure 4. Sample trade journal output showing how maximum adverse excursion, maximum favorable excursion, and R-multiple data can be evaluated and analyzed further.
Backtesting
To test the demonstration EA, open the MetaTrader 5 Strategy Tester and configure it as follows. Symbol = EURUSD. Timeframe = H1. Modeling = every tick based on real ticks. Initial deposit = $10,000. Test period = 2022.01.01 to 2024.12.31. Use "Fast_MA_Period" = 20, "Slow_MA_Period" = 50, "ATR_Period" = 14, "SL_ATR_Mult" = 1.5, "RR" = 2.0, and "Journal_File" = "TradeJournal."
After the test completes, locate the CSV in the common files folder—typically "C:\Users\[Username]\AppData\Roaming\MetaQuotes\Terminal\Common\Files\TradeJournal." Open it in a spreadsheet application and compute the following.
Average R-multiple: the mean of the "R_multiple" column. A positive average confirms positive expectancy. A negative average reveals that the strategy is losing regardless of win rate.
Maximum adverse excursion-to-risk ratio: for each row, divide maximum adverse excursion by "InitialRiskUSD." A mean ratio above 0.5 means positions frequently experience more than half their initial risk as adverse excursion before recovering—a sign of poor entry timing or overly tight stops.
Maximum favorable excursion-to-winner ratio: for winning trades, divide maximum favorable excursion by "ProfitUSD." A mean ratio above 2.0 means the strategy is capturing less than half of what was available. A trailing stop tuned to this ratio would substantially improve performance.
Test Results

Figure 5. Demonstration test.

Figure 6. Equity & balance curve.

Figure 7. Test results.

Figure 8. Entries.

Figure 9. Maximum adverse excursion, maximum favorable excursion correlation.
Known Limitations
The journal tracks positions by position ID from the deal record. On netting accounts, where adding to a position does not create a new ticket, "NotifyOpen()" called on the same position twice will create a duplicate record. The demonstration EA and "CTradeJournal" are designed for hedging accounts—each position has its own ticket, and the journal tracks each independently. On netting accounts, the calling EA must ensure "NotifyOpen()" is called only once per unique position ID.
The "duration_bars" parameter requires the calling EA to maintain its own bar counter. The demonstration EA uses "g_bar_count" incremented on each new bar from EA start—not from position open. To report per-trade duration accurately, store the bar count at "NotifyOpen()" time and pass the difference at "NotifyClose."
The CSV file grows indefinitely. There is no built-in rotation or archiving. For long-running live deployments, implement periodic file rotation by calling "OnDeinit()" and "Init()" with a dated filename at the start of each month.
The journal does not track pending orders. It tracks only market positions that have been registered via "NotifyOpen()." The calling EA is responsible for calling "NotifyOpen()" at the correct moment.
The code is a demonstration architecture. Before live deployment, verify that "FILE_COMMON" resolves to the correct path on your installation, confirm that the CSV is accessible from your analysis tools while the EA is running, and test "NotifyClose()" behavior if a position is closed externally—for example, by a manual close or a separate EA.
Conclusion
The difference between a strategy that improves over time and one that does not is measurement. A developer who reviews the journal at the end of each month—looking at R-multiple distribution, maximum adverse excursion ratios, and maximum favorable excursion efficiency—accumulates information that changes how the next version of their EA is designed. A developer with no journal starts from zero with every iteration.
"CTradeJournal" makes that measurement possible without requiring the calling EA to know anything about file handling, tick-level tracking, or R-multiple computation. The class does one thing: it tracks open positions at tick frequency and writes a complete, structured record when each one closes. The calling EA does one thing: it tells the journal what it opened and passes the indicator context it cares about. The boundary between them is clean, the integration is four steps, and the output is a CSV file that answers the questions the backtest equity curve cannot.
Write it once. Drop it into every EA you build. Let the data tell you what matters.
All code was compiled and tested in MetaTrader 5. Always run a full Strategy Tester pass on a demo account before live deployment.
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.
MQL5 Custom Symbols: Creating a 3D Bars Symbol
CSV Data Analysis (Part 1): CSV Export Engine for MQL5 Multi-Core Optimizations
Features of Experts Advisors
Exploring Regression Models for Causal Inference and Trading
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use