preview
Position Management: A Reusable Trade Journal with Live Maximum Adverse Excursion, Maximum Favorable Excursion, and R-Multiple Tracking in MQL5

Position Management: A Reusable Trade Journal with Live Maximum Adverse Excursion, Maximum Favorable Excursion, and R-Multiple Tracking in MQL5

MetaTrader 5Trading |
166 0
Tola Moses Hector
Tola Moses Hector

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.

MAE VS MFE

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:

  1. Why Post-Trade Logging Loses the Information That Matters
  2. What the R-Multiple Tells You That Profit Does Not
  3. Architecture: Two Files, One Clear Responsibility Each
  4. Implementation in MQL5
  5. Integrating the Journal Into Your Own EA
  6. The Demonstration EA
  7. What the CSV Produces
  8. Backtesting
  9. Known Limitations
  10. 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.

trade a vs trade b

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.

  1. "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.
  2. "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.

PUBLIC INTERFACE

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.

csv

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

JournalEA demonstration

Figure 5. Demonstration test.

equity and balance curve

Figure 6. Equity & balance curve.

results

Figure 7. Test results.

entries

Figure 8. Entries.

MFE, MAE

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.

Attached files |
JournalDemoEA.mq5 (9.12 KB)
MQL5 Custom Symbols: Creating a 3D Bars Symbol MQL5 Custom Symbols: Creating a 3D Bars Symbol
The article provides a detailed guide to creating the innovative 3DBarCustomSymbol.mq5 indicator, which generates custom symbols in MetaTrader 5 that combine price, time, volume, and volatility into a single three-dimensional representation. The mathematical foundations, system architecture, practical aspects of implementation and application in trading strategies are considered.
CSV Data Analysis (Part 1): CSV Export Engine for MQL5 Multi-Core Optimizations CSV Data Analysis (Part 1): CSV Export Engine for MQL5 Multi-Core Optimizations
Multi-core optimization in MetaTrader 5 can silently drop results when parallel agents contend for the same CSV file. A reusable MQL5 export engine applies an iteration-based spin-lock to acquire the file handle reliably and append rows without loss. It persists custom metrics such as the Sortino Ratio, average trade duration, and signal-quality measures (lag and whipsaws) into a consolidated CSV for downstream analysis.
Features of Experts Advisors Features of Experts Advisors
Creation of expert advisors in the MetaTrader trading system has a number of features.
Exploring Regression Models for Causal Inference and Trading Exploring Regression Models for Causal Inference and Trading
The article explores the possibility of using regression models in algorithmic trading. Regression models, unlike binary classification, allow for the creation of more flexible trading strategies by quantifying predicted price changes.