Building a Session Performance Analytics Dashboard in MQL5
Introduction
Trading statistics in MetaTrader read like a smooth, undifferentiated feed: time, symbol, and profit for every deal, but no answer to the question many traders actually want to test, which market hours generate profit and which quietly drag results down. A trader testing the hypothesis that a strategy performs well during the London session but suffers overnight in Sydney gets no ready-made breakdown by session from the terminal. This usually ends in exporting history to a spreadsheet, marking rows by UTC hour by hand, and repeating the same routine for every new period reviewed. What is needed instead is a way to read closed deals for a chosen lookback window, assign each one to a trading session by its close time, and aggregate the metrics so every session's contribution shows on one screen.
This article builds a dashboard that removes that manual step. A script reads the terminal's closed-deal history for a configurable lookback window. It classifies each deal into one of four trading sessions based on its UTC close time, computes per-session metrics (net P&L, win rate, trade count, and average hold time), and renders a color-coded CCanvas bar chart. A parallel summary table goes to the Experts tab. The sections below walk through six include files and a main script function by function, followed by a verification script.

Architecture diagram: SessionDashboard.mq5 runs the pipeline top to bottom through the history reader, classifier, and aggregator, then fans out to the bar chart and table printer, both of which land in the trader-facing chart panel and Experts tab.
Section 1 — SessionEnums.mqh: Session Boundaries and Data Structures
A trading session is one of four fixed UTC windows: Sydney, Tokyo, London, and New York. UTC is used as the reference frame rather than the broker's own server time, since server time shifts with daylight saving rules that differ by broker, while UTC never shifts.
ENUM_TRADING_SESSION names the four sessions plus a SESSION_UNKNOWN fallback for a deal that matches no configured boundary.
//+------------------------------------------------------------------+ //| SessionEnums.mqh| //+------------------------------------------------------------------+ #ifndef SESSIONENUMS_MQH #define SESSIONENUMS_MQH //+------------------------------------------------------------------+ //| ENUM_TRADING_SESSION | //+------------------------------------------------------------------+ enum ENUM_TRADING_SESSION { SESSION_SYDNEY = 0, SESSION_TOKYO, SESSION_LONDON, SESSION_NEW_YORK, SESSION_UNKNOWN };
CSessionBoundary describes one session's identity: which session it is, its display name, its UTC open and close hour, and the bar color used when it is profitable. Sydney's open hour (22) is larger than its close hour (7); that mismatch is the signal the classifier later uses to detect a boundary spanning midnight.
//+------------------------------------------------------------------+ //| CSessionBoundary | //| Describes one trading session's UTC open and close hour, its | //| display name, and the color used to draw a profitable bar for | //| that session. | //+------------------------------------------------------------------+ struct CSessionBoundary { ENUM_TRADING_SESSION session_type; string name; int utc_open_hour; int utc_close_hour; color bar_color; };
CSessionMetrics holds the aggregated numbers for one session: trade count, win count, net P&L, total hold time in seconds, and an average hold time already divided out. Storing both the total and the average means the aggregator only performs that division once.
//+------------------------------------------------------------------+ //| CSessionMetrics | //| Holds the computed session metrics for one trading session: | //| trade count, win count, net P&L, and hold time totals. | //+------------------------------------------------------------------+ struct CSessionMetrics { ENUM_TRADING_SESSION session_type; string name; int trade_count; int win_count; double net_pnl; long total_hold_time; double average_hold_time; };
CDealRecord is the smallest unit the pipeline works with, one closed deal reduced to just the fields the rest of the system needs, including the session_type assigned by the classifier.
//+------------------------------------------------------------------+ //| CDealRecord | //| Holds the minimal fields read from one closed deal, including | //| the trading session it has been classified into. | //+------------------------------------------------------------------+ struct CDealRecord { ulong ticket; datetime open_time; datetime close_time; double profit; string symbol; ENUM_TRADING_SESSION session_type; };
Section 2 — CDealHistoryReader: Reading Closed Deals
The reader's job is to pull closed deals out of the terminal's history and hand back a clean array of records.
//+------------------------------------------------------------------+ //| DealHistoryReader.mqh | //+------------------------------------------------------------------+ #ifndef DEALHISTORYREADER_MQH #define DEALHISTORYREADER_MQH #include "SessionEnums.mqh" //+------------------------------------------------------------------+ //| CDealHistoryReader | //| Reads closed deal history for a date range and populates an | //| array of CDealRecord entries, skipping balance and deposit | //| deals and recovering each deal's open time from its position. | //+------------------------------------------------------------------+ class CDealHistoryReader { private: datetime FindOpenTime(ulong position_id, datetime close_time_utc) const; public: CDealHistoryReader(void); ~CDealHistoryReader(void); int Read(datetime from, datetime to, CDealRecord &records[]); }; //+------------------------------------------------------------------+ //| Constructor: no member state to initialize. | //+------------------------------------------------------------------+ CDealHistoryReader::CDealHistoryReader(void) { } //+------------------------------------------------------------------+ //| Destructor: no dynamic resources to release. | //+------------------------------------------------------------------+ CDealHistoryReader::~CDealHistoryReader(void) { }
Read() starts by scoping the history cache with HistorySelect(), then iterates every deal in that range. Only DEAL_ENTRY_OUT and DEAL_ENTRY_INOUT deals are kept, since these represent an actual closed deal rather than a balance operation or an opening deal.
//+------------------------------------------------------------------+ //| Read | //+------------------------------------------------------------------+ int CDealHistoryReader::Read(datetime from, datetime to, CDealRecord &records[]) { //--- scope the terminal's history cache to the requested range if(!::HistorySelect(from, to)) { ::Print("SessionDashboard: HistorySelect failed, error ", ::GetLastError()); return(0); } //--- resize the output array to the maximum possible size int total = ::HistoryDealsTotal(); ::ArrayResize(records, total); int populated = 0; ulong ticket = 0; long entry_type = 0; double profit = 0.0; string symbol = ""; datetime close_time_utc = 0; datetime open_time = 0; ulong position_id = 0; //--- iterate every deal in the selected history range for(int i = 0; i < total; i++) { ticket = ::HistoryDealGetTicket(i); if(ticket == 0) continue; //--- read the entry type and skip anything that is not a closing deal entry_type = ::HistoryDealGetInteger(ticket, DEAL_ENTRY); if(entry_type != DEAL_ENTRY_OUT && entry_type != DEAL_ENTRY_INOUT) continue; //--- read the fields needed for this deal record profit = ::HistoryDealGetDouble(ticket, DEAL_PROFIT); symbol = ::HistoryDealGetString(ticket, DEAL_SYMBOL); close_time_utc = (datetime)::HistoryDealGetInteger(ticket, DEAL_TIME); position_id = (ulong)::HistoryDealGetInteger(ticket, DEAL_POSITION_ID); //--- recover the open time from the earliest deal on this position open_time = FindOpenTime(position_id, close_time_utc); //--- populate the record and advance the output count records[populated].ticket = ticket; records[populated].open_time = open_time; records[populated].close_time = close_time_utc; records[populated].profit = profit; records[populated].symbol = symbol; records[populated].session_type = SESSION_UNKNOWN; populated++; } //--- shrink the array down to the number of records actually populated ::ArrayResize(records, populated); return(populated); }
FindOpenTime() recovers a position's opening time by scanning for the earliest deal sharing the same DEAL_POSITION_ID. When that earlier deal cannot be found, the search falls back to the close time itself, giving a hold time of zero for that record.
//+------------------------------------------------------------------+ //| FindOpenTime | //+------------------------------------------------------------------+ datetime CDealHistoryReader::FindOpenTime(ulong position_id, datetime close_time_utc) const { int total = ::HistoryDealsTotal(); datetime earliest = close_time_utc; bool found = false; ulong ticket = 0; ulong scanned_id = 0; datetime scanned_time = 0; //--- scan every deal for one that shares the target position id for(int i = 0; i < total; i++) { ticket = ::HistoryDealGetTicket(i); if(ticket == 0) continue; scanned_id = (ulong)::HistoryDealGetInteger(ticket, DEAL_POSITION_ID); if(scanned_id != position_id) continue; scanned_time = (datetime)::HistoryDealGetInteger(ticket, DEAL_TIME); //--- keep the earliest matching time seen so far if(!found || scanned_time < earliest) { earliest = scanned_time; found = true; } } return(earliest); }
Section 3 — CSessionClassifier: Classifying Deals by Session
The classifier's job is to look at a deal's UTC close time and decide which of the four sessions it belongs to. Close time is the natural anchor here rather than open time, since a session breakdown is meant to show which market conditions a trade was actually resolved in. A position can be opened during one session and closed during another; classifying by the close time credits the session whose conditions actually produced the outcome, which is the session a trader is trying to evaluate.
//+------------------------------------------------------------------+ //| SessionClassifier.mqh| //+------------------------------------------------------------------+ #ifndef SESSIONCLASSIFIER_MQH #define SESSIONCLASSIFIER_MQH #include "SessionEnums.mqh" #define SESSION_BOUNDARY_COUNT 4 //+------------------------------------------------------------------+ //| CSessionClassifier | //| Classifies a UTC datetime into one of the four configured | //| trading sessions. | //+------------------------------------------------------------------+ class CSessionClassifier { private: CSessionBoundary m_boundaries[SESSION_BOUNDARY_COUNT]; int m_boundary_count; public: CSessionClassifier(void); ~CSessionClassifier(void); void LoadDefaults(void); ENUM_TRADING_SESSION Classify(datetime close_time_utc) const; int BoundaryCount(void) const; bool GetBoundary(int index, CSessionBoundary &boundary_out) const; }; //+------------------------------------------------------------------+ //| Constructor: initializes the boundary count to zero until | //| LoadDefaults or a custom loader populates the array. | //+------------------------------------------------------------------+ CSessionClassifier::CSessionClassifier(void) { m_boundary_count = 0; } //+------------------------------------------------------------------+ //| Destructor: no dynamic resources to release. | //+------------------------------------------------------------------+ CSessionClassifier::~CSessionClassifier(void) { }
LoadDefaults() populates the four standard sessions with their commonly accepted UTC boundaries. London and New York overlap between 13:00 and 17:00 UTC, since both centers are genuinely open at once. London is inserted before New York on purpose, because Classify() resolves overlaps by returning the first matching boundary.
//+------------------------------------------------------------------+ //| LoadDefaults | //+------------------------------------------------------------------+ void CSessionClassifier::LoadDefaults(void) { //--- define the Sydney session, which wraps past midnight in UTC m_boundaries[0].session_type = SESSION_SYDNEY; m_boundaries[0].name = "Sydney"; m_boundaries[0].utc_open_hour = 22; m_boundaries[0].utc_close_hour = 7; m_boundaries[0].bar_color = clrForestGreen; //--- define the Tokyo session m_boundaries[1].session_type = SESSION_TOKYO; m_boundaries[1].name = "Tokyo"; m_boundaries[1].utc_open_hour = 0; m_boundaries[1].utc_close_hour = 9; m_boundaries[1].bar_color = clrForestGreen; //--- define the London session, inserted before New York deliberately m_boundaries[2].session_type = SESSION_LONDON; m_boundaries[2].name = "London"; m_boundaries[2].utc_open_hour = 8; m_boundaries[2].utc_close_hour = 17; m_boundaries[2].bar_color = clrForestGreen; //--- define the New York session m_boundaries[3].session_type = SESSION_NEW_YORK; m_boundaries[3].name = "New York"; m_boundaries[3].utc_open_hour = 13; m_boundaries[3].utc_close_hour = 22; m_boundaries[3].bar_color = clrForestGreen; //--- record how many boundaries were loaded m_boundary_count = SESSION_BOUNDARY_COUNT; }
Classify() extracts the UTC hour with TimeToStruct() and checks it against each boundary in order. A single branch handles both same-day windows and the midnight-crossing Sydney window, so no boundary needs special-casing by name.
//+------------------------------------------------------------------+ //| Classify | //+------------------------------------------------------------------+ ENUM_TRADING_SESSION CSessionClassifier::Classify(datetime close_time_utc) const { //--- extract the UTC hour component from the close time MqlDateTime dt; ::TimeToStruct(close_time_utc, dt); int hour = dt.hour; //--- compare the hour against each configured boundary in order for(int i = 0; i < m_boundary_count; i++) { int open_hour = m_boundaries[i].utc_open_hour; int close_hour = m_boundaries[i].utc_close_hour; //--- same-day boundary: open hour precedes close hour if(open_hour < close_hour) { if(hour >= open_hour && hour < close_hour) return(m_boundaries[i].session_type); } //--- midnight-crossing boundary: open hour follows close hour else { if(hour >= open_hour || hour < close_hour) return(m_boundaries[i].session_type); } } //--- no configured boundary matched this hour return(SESSION_UNKNOWN); }
BoundaryCount() and GetBoundary() let a caller, such as the verification script, inspect the loaded boundaries without duplicating them elsewhere.
//+------------------------------------------------------------------+ //| BoundaryCount | //| Returns the number of boundaries currently loaded. | //+------------------------------------------------------------------+ int CSessionClassifier::BoundaryCount(void) const { return(m_boundary_count); } //+------------------------------------------------------------------+ //| GetBoundary | //| Copies the boundary at the given index into boundary_out. | //| Returns false if the index is out of range. | //+------------------------------------------------------------------+ bool CSessionClassifier::GetBoundary(int index, CSessionBoundary &boundary_out) const { //--- validates the requested index before copying if(index < 0 || index >= m_boundary_count) return(false); boundary_out = m_boundaries[index]; return(true); }
This classification assumes a deal's close time is already reported in UTC, the convention MQL5's own history functions follow; Section 10 covers what to check if that assumption does not hold for a given broker.
Section 4 — CSessionAggregator: Computing Session Metrics and Account Totals
The aggregator collapses the flat deal array into one CSessionMetrics entry per trading session, and can also fold those entries into a single set of account-wide totals.
//+------------------------------------------------------------------+ //| SessionAggregator.mqh | //+------------------------------------------------------------------+ #ifndef SESSIONAGGREGATOR_MQH #define SESSIONAGGREGATOR_MQH #include "SessionEnums.mqh" #define SESSION_TYPE_COUNT 5 //+------------------------------------------------------------------+ //| CSessionAggregator | //| Groups deal records by session type and computes net P&L, win | //| rate inputs, trade count, and average hold time for each. | //+------------------------------------------------------------------+ class CSessionAggregator { private: string SessionName(ENUM_TRADING_SESSION session_type) const; public: CSessionAggregator(void); ~CSessionAggregator(void); int Aggregate(const CDealRecord &records[], int count, CSessionMetrics &metrics[]); void ComputeTotals(const CSessionMetrics &metrics[], int count, int &total_trades_out, double &total_win_rate_out, double &total_net_pnl_out); }; //+------------------------------------------------------------------+ //| Constructor: no member state to initialize. | //+------------------------------------------------------------------+ CSessionAggregator::CSessionAggregator(void) { } //+------------------------------------------------------------------+ //| Destructor: no dynamic resources to release. | //+------------------------------------------------------------------+ CSessionAggregator::~CSessionAggregator(void) { }
Aggregate() scans the full deal array once for each of the five possible session types, accumulating trade count, win count, net P&L, and total hold time. With only five session types and a deal count that rarely exceeds a few thousand rows, this repeated scan is fast enough that a more complex grouping structure would not be worth the added complexity.
//+------------------------------------------------------------------+ //| Aggregate | //+------------------------------------------------------------------+ int CSessionAggregator::Aggregate(const CDealRecord &records[], int count, CSessionMetrics &metrics[]) { //--- size the metrics array to hold every possible session type ::ArrayResize(metrics, SESSION_TYPE_COUNT); int trade_count = 0; int win_count = 0; double net_pnl = 0.0; long total_hold_time = 0; long hold_seconds = 0; ENUM_TRADING_SESSION current_type = SESSION_SYDNEY; //--- compute metrics for each of the five possible session types for(int s = 0; s < SESSION_TYPE_COUNT; s++) { current_type = (ENUM_TRADING_SESSION)s; trade_count = 0; win_count = 0; net_pnl = 0.0; total_hold_time = 0; //--- scan every deal record for matches to the current session type for(int i = 0; i < count; i++) { if(records[i].session_type != current_type) continue; trade_count++; net_pnl += records[i].profit; if(records[i].profit > 0.0) win_count++; hold_seconds = (long)(records[i].close_time - records[i].open_time); total_hold_time += hold_seconds; } //--- populate the metrics entry for this session type metrics[s].session_type = current_type; metrics[s].name = SessionName(current_type); metrics[s].trade_count = trade_count; metrics[s].win_count = win_count; metrics[s].net_pnl = net_pnl; metrics[s].total_hold_time = total_hold_time; //--- guard against division by zero for sessions with no trades if(trade_count > 0) metrics[s].average_hold_time = (double)total_hold_time / (double)trade_count; else metrics[s].average_hold_time = 0.0; } return(SESSION_TYPE_COUNT); }
ComputeTotals() sums trade count, win count, and net P&L across every entry in the metrics array, including SESSION_UNKNOWN, so the total always matches the number of records the reader pulled from history. This is what lets the bar chart's summary row report a single account-wide trade count, win rate, and net P&L alongside the four individual session bars.
//+------------------------------------------------------------------+ //| ComputeTotals | //+------------------------------------------------------------------+ void CSessionAggregator::ComputeTotals(const CSessionMetrics &metrics[], int count, int &total_trades_out, double &total_win_rate_out, double &total_net_pnl_out) { int total_trades = 0; int total_wins = 0; double total_pnl = 0.0; //--- sum trade count, win count, and net P&L across every session for(int i = 0; i < count; i++) { total_trades += metrics[i].trade_count; total_wins += metrics[i].win_count; total_pnl += metrics[i].net_pnl; } total_trades_out = total_trades; //--- guard against division by zero when no trades exist at all if(total_trades > 0) total_win_rate_out = ((double)total_wins / (double)total_trades) * 100.0; else total_win_rate_out = 0.0; total_net_pnl_out = total_pnl; }
SessionName() is a small private helper that maps the enum value to the display string used by both the bar chart and the table printer.
//+------------------------------------------------------------------+ //| SessionName | //| Returns the display name for a given session type. | //+------------------------------------------------------------------+ string CSessionAggregator::SessionName(ENUM_TRADING_SESSION session_type) const { switch(session_type) { case SESSION_SYDNEY: return("Sydney"); case SESSION_TOKYO: return("Tokyo"); case SESSION_LONDON: return("London"); case SESSION_NEW_YORK: return("New York"); default: return("Unknown"); } }
Section 5 — CSessionBarChart: Rendering with CCanvas
The canvas panel is created with CCanvas::CreateBitmapLabel(), which attaches a single bitmap object to the chart that the class has full pixel-level control over.
//+------------------------------------------------------------------+ //| SessionBarChart.mqh | //+------------------------------------------------------------------+ #ifndef SESSIONBARCHART_MQH #define SESSIONBARCHART_MQH #include <Canvas\Canvas.mqh> #include "SessionEnums.mqh" //+------------------------------------------------------------------+ //| CSessionBarChart | //| Renders session metrics as a horizontal, color-coded bar chart | //| on the chart using a CCanvas panel. | //+------------------------------------------------------------------+ class CSessionBarChart { private: CCanvas m_canvas; string m_object_name; bool m_created; string m_font_name; int m_font_size; public: CSessionBarChart(void); ~CSessionBarChart(void); bool Draw(const CSessionMetrics &metrics[], int count, int total_trades, double total_win_rate, double total_net_pnl, int x, int y, int width, int height); void Clear(void); }; //+------------------------------------------------------------------+ //| Constructor: assigns a unique object name for the canvas panel, | //| fixes the font used for both drawing and measuring text, and | //| marks the panel as not yet created. | //+------------------------------------------------------------------+ CSessionBarChart::CSessionBarChart(void) { m_object_name = "SessionBarChartPanel"; m_created = false; m_font_name = "Arial"; m_font_size = 16; } //+------------------------------------------------------------------+ //| Destructor: intentionally does not remove the canvas panel. The | //| panel is a chart object owned by the chart itself, and must | //| remain visible after the CSessionBarChart instance that drew it | //| goes out of scope at the end of a script's OnStart. | //+------------------------------------------------------------------+ CSessionBarChart::~CSessionBarChart(void) { }
Draw() now takes three extra parameters: the account-wide total trade count, win rate, and net P&L, alongside the per-session metrics it already drew. It reserves one extra row at the bottom of the panel for these totals, dividing the panel height by count + 1 instead of count so the summary row gets the same vertical space as a session bar.
//+------------------------------------------------------------------+ //| Draw | //+------------------------------------------------------------------+ bool CSessionBarChart::Draw(const CSessionMetrics &metrics[], int count, int total_trades, double total_win_rate, double total_net_pnl, int x, int y, int width, int height) { //--- removes any previously drawn panel before creating a new one Clear(); //--- creates the bitmap label that the canvas will draw onto if(!m_canvas.CreateBitmapLabel(m_object_name, x, y, width, height, COLOR_FORMAT_ARGB_NORMALIZE)) { ::Print("SessionDashboard: CreateBitmapLabel failed, error ", ::GetLastError()); return(false); } m_created = true; //--- fix a known font for both drawing and measurement, so the two never disagree m_canvas.FontSet(m_font_name, m_font_size); ::TextSetFont(m_font_name, m_font_size); //--- clears the panel to a light background before drawing m_canvas.Erase(::ColorToARGB(clrWhiteSmoke, 255)); //--- finds the largest absolute net P&L among sessions with trades double max_abs_pnl = 0.0; for(int i = 0; i < count; i++) { if(metrics[i].trade_count == 0) continue; double abs_pnl = ::MathAbs(metrics[i].net_pnl); if(abs_pnl > max_abs_pnl) max_abs_pnl = abs_pnl; } if(max_abs_pnl <= 0.0) max_abs_pnl = 1.0; //--- reserves one extra row at the bottom of the panel for the summary line int zero_x = width / 2; int half_width = (width / 2) - 20; int row_height = height / (count + 1); //--- draws the vertical zero line spanning only the session bar rows m_canvas.LineVertical(zero_x, 0, row_height * count, ::ColorToARGB(clrDarkGray, 255)); //--- draws one bar per session int bar_y = 0; int bar_x = 0; int bar_width = 0; uint bar_color = 0; string pnl_text = ""; for(int i = 0; i < count; i++) { bar_y = i * row_height + (row_height / 4); //--- Uses green for profitable sessions and red for the rest bar_color = (metrics[i].net_pnl > 0.0) ? ::ColorToARGB(clrForestGreen, 255) : ::ColorToARGB(clrCrimson, 255); //--- computes bar width proportional to this session's net P&L bar_width = (int)((::MathAbs(metrics[i].net_pnl) / max_abs_pnl) * half_width); //--- draws the bar extending right for profit, left for loss if(metrics[i].net_pnl > 0.0) bar_x = zero_x; else bar_x = zero_x - bar_width; m_canvas.FillRectangle(bar_x, bar_y, bar_x + bar_width, bar_y + (row_height / 2), bar_color); //--- draws the session name label on the left side of the panel m_canvas.TextOut(10, bar_y, metrics[i].name, ::ColorToARGB(clrBlack, 255)); //--- draws the net P&L value on the right side of the panel pnl_text = ::DoubleToString(metrics[i].net_pnl, 2); m_canvas.TextOut(width - 90, bar_y, pnl_text, ::ColorToARGB(clrBlack, 255)); } //--- draw a divider line separating the bars from the summary row int summary_y = row_height * count; m_canvas.LineHorizontal(0, summary_y, width, ::ColorToARGB(clrDarkGray, 255)); //--- draws the three account-wide totals in sequence, each positioned after //--- the ACTUAL measured width of the previous one plus a fixed gap, so a //--- longer string can never overlap the one that follows it int text_y = summary_y + (row_height / 4); int gap = 16; uint measured_w = 0; uint measured_h = 0; string trades_text = "Trades: " + ::IntegerToString(total_trades); string winrate_text = "Win rate: " + ::DoubleToString(total_win_rate, 1) + "%"; string netpnl_text = "Net P&L: " + ::DoubleToString(total_net_pnl, 2); int trades_x = 10; ::TextGetSize(trades_text, measured_w, measured_h); int winrate_x = trades_x + (int)measured_w + gap; ::TextGetSize(winrate_text, measured_w, measured_h); int netpnl_x = winrate_x + (int)measured_w + gap; m_canvas.TextOut(trades_x, text_y, trades_text, ::ColorToARGB(clrBlack, 255)); m_canvas.TextOut(winrate_x, text_y, winrate_text, ::ColorToARGB(clrBlack, 255)); m_canvas.TextOut(netpnl_x, text_y, netpnl_text, ::ColorToARGB(clrBlack, 255)); //--- commit the drawing to the chart m_canvas.Update(); return(true); }
Immediately after creating the panel, Draw() calls CCanvas::FontSet() and the global ::TextSetFont() with the same name and size. FontSet() fixes what the canvas itself draws with; ::TextSetFont() fixes what the global ::TextGetSize() function measures against. Calling both with identical arguments means a measurement taken later is guaranteed to match what actually gets drawn.
The bar-drawing loop itself is unchanged from computing each session's proportional bar width against the largest absolute net P&L, and coloring it green or red by the sign of net_pnl.
Clear() removes the bitmap and resets the created flag. It still runs at the top of Draw() so a redraw does not leave a stale bitmap behind, and it remains available for any caller who wants to tear the panel down explicitly.
//+------------------------------------------------------------------+ //| Clear | //| Removes the canvas panel from the chart if it was created. | //+------------------------------------------------------------------+ void CSessionBarChart::Clear(void) { if(!m_created) return; m_canvas.Destroy(); m_created = false; }
Section 6 — CSessionTablePrinter: Experts tab Summary
A plain-text table serves a different purpose than the canvas panel: it can be copied into a report or compared line by line against a previous run without opening the chart.
//+------------------------------------------------------------------+ //| SessionTablePrinter.mqh | //+------------------------------------------------------------------+ #ifndef SESSIONTABLEPRINTER_MQH #define SESSIONTABLEPRINTER_MQH #include "SessionEnums.mqh" //+------------------------------------------------------------------+ //| CSessionTablePrinter | //| Prints a formatted summary table of session metrics to the | //| Experts tab with fixed-width columns. | //+------------------------------------------------------------------+ class CSessionTablePrinter { private: string PadRight(string value, int width) const; public: CSessionTablePrinter(void); ~CSessionTablePrinter(void); void Print(const CSessionMetrics &metrics[], int count); }; //+------------------------------------------------------------------+ //| Constructor: no member state to initialize. | //+------------------------------------------------------------------+ CSessionTablePrinter::CSessionTablePrinter(void) { } //+------------------------------------------------------------------+ //| Destructor: no dynamic resources to release. | //+------------------------------------------------------------------+ CSessionTablePrinter::~CSessionTablePrinter(void) { }
Print() writes the header row and then one row per session, in the order a trader would naturally scan: name, trade count, win rate, net P&L, and average hold time.
//+------------------------------------------------------------------+ //| Print | //| Outputs a formatted table to the terminal log with columns for | //| session name, trade count, win rate, net P&L, and average hold | //| time, one row per session. | //+------------------------------------------------------------------+ void CSessionTablePrinter::Print(const CSessionMetrics &metrics[], int count) { string header = ""; string row = ""; double win_rate = 0.0; //--- print the header row with fixed-width column labels header = PadRight("Session", 12) + PadRight("Trades", 10) + PadRight("Win Rate", 12) + PadRight("Net P&L", 14) + PadRight("Avg Hold(s)", 14); ::Print(header); //--- print one row per session with the same fixed-width columns for(int i = 0; i < count; i++) { //--- guard against division by zero for sessions with no trades if(metrics[i].trade_count > 0) win_rate = ((double)metrics[i].win_count / (double)metrics[i].trade_count) * 100.0; else win_rate = 0.0; row = PadRight(metrics[i].name, 12) + PadRight(::IntegerToString(metrics[i].trade_count), 10) + PadRight(::DoubleToString(win_rate, 1) + "%", 12) + PadRight(::DoubleToString(metrics[i].net_pnl, 2), 14) + PadRight(::DoubleToString(metrics[i].average_hold_time, 1), 14); ::Print(row); } }
PadRight() is the shared column-alignment helper the header and every row lean on, padding with trailing spaces or leaving a value unchanged if it already fills the column.
//+------------------------------------------------------------------+ //| PadRight | //+------------------------------------------------------------------+ string CSessionTablePrinter::PadRight(string value, int width) const { int need = width - ::StringLen(value); //--- return the value unchanged if it already fills the column if(need <= 0) return(value); string result = value; for(int i = 0; i < need; i++) result += " "; return(result); }
Section 7 — SessionDashboard.mq5: Assembling the Main Script
This script serves as a coordinator. It contains no core business logic of its own; instead, it instantiates and connects the five previously defined classes to execute the data pipeline in its natural sequence.
Its only standalone task is to convert the user's inputs into a concrete date range. For simpler configuration, the script accepts a lookback-days parameter instead of exact datetime inputs. It then anchors this lookback period against TimeCurrent() to dynamically calculate the precise from and to timestamps required by the history reader.
//+------------------------------------------------------------------+ //| SessionDashboard.mq5 | //+------------------------------------------------------------------+ #property script_show_inputs #include <SessionDashboard/SessionEnums.mqh> #include <SessionDashboard/SessionClassifier.mqh> #include <SessionDashboard/DealHistoryReader.mqh> #include <SessionDashboard/SessionAggregator.mqh> #include <SessionDashboard/SessionBarChart.mqh> #include <SessionDashboard/SessionTablePrinter.mqh> input int InpLookbackDays = 30; // Number of days to look back from the current time input int InpPanelX = 20; // Canvas panel X coordinate input int InpPanelY = 20; // Canvas panel Y coordinate input int InpPanelWidth = 420; // Canvas panel width in pixels input int InpPanelHeight = 260; // Canvas panel height in pixels
OnStart() resolves the lookback into a concrete date range, reads, classifies, and aggregates, then calls ComputeTotals() right after aggregation to fold the per-session metrics into a single set of account-wide numbers. Those totals are passed straight into Draw() alongside the per-session metrics, so the canvas panel's summary row always reflects the same data as the four bars above it.
//+------------------------------------------------------------------+ //| OnStart | //+------------------------------------------------------------------+ void OnStart(void) { //--- resolve the date range from the lookback input datetime to_time = ::TimeCurrent(); datetime from_time = to_time - (InpLookbackDays * 86400); //--- read closed deal history for the resolved date range CDealHistoryReader reader; CDealRecord records[]; int record_count = reader.Read(from_time, to_time, records); ::Print("SessionDashboard: read ", record_count, " deal records"); if(record_count == 0) { ::Print("SessionDashboard: no closed deals found in the selected range"); return; } //--- classify every deal record into a trading session CSessionClassifier classifier; classifier.LoadDefaults(); for(int i = 0; i < record_count; i++) records[i].session_type = classifier.Classify(records[i].close_time); //--- aggregate the classified records into per-session metrics CSessionAggregator aggregator; CSessionMetrics metrics[]; int metrics_count = aggregator.Aggregate(records, record_count, metrics); ::Print("SessionDashboard: aggregated ", metrics_count, " session metric rows"); //--- fold the per-session metrics into account-wide totals int total_trades = 0; double total_win_rate = 0.0; double total_net_pnl = 0.0; aggregator.ComputeTotals(metrics, metrics_count, total_trades, total_win_rate, total_net_pnl); //--- render the canvas panel bar chart, including the summary row CSessionBarChart bar_chart; bar_chart.Draw(metrics, metrics_count, total_trades, total_win_rate, total_net_pnl, InpPanelX, InpPanelY, InpPanelWidth, InpPanelHeight); //--- print the summary table to the Experts tab CSessionTablePrinter printer; printer.Print(metrics, metrics_count); }
Section 8 — TestSessionAnalytics.mq5: Verification Script
The verification script serves as a unit test suite targeting the logic most prone to edge-case bugs: exact boundary hour classification, the midnight-spanning Sydney session, P&L aggregation, and average hold time calculations. Each test constructs a minimal input array to isolate specific class behaviors. The outputs are then validated using a custom ASSERT macro, forcing explicit pass/fail logs in the Experts tab to prevent silent execution failures.
//+------------------------------------------------------------------+ //| TestSessionAnalytics.mq5 | //+------------------------------------------------------------------+ #include <SessionDashboard/SessionEnums.mqh> #include <SessionDashboard/SessionClassifier.mqh> #include <SessionDashboard/SessionAggregator.mqh> #define ASSERT(condition, message) TestAssert((condition), (message)) int g_pass_count = 0; int g_fail_count = 0; //+------------------------------------------------------------------+ //| TestAssert | //| Prints a pass or fail message for a single test condition and | //| tracks the running pass and fail counts. | //+------------------------------------------------------------------+ void TestAssert(bool condition, string message) { if(condition) { g_pass_count++; ::Print("PASS: ", message); } else { g_fail_count++; ::Print("FAIL: ", message); } }
BuildDateTime() constructs a fixed test date at a given hour, so every test operates on the same calendar day and only the hour varies.
//+------------------------------------------------------------------+ //| BuildDateTime | //+------------------------------------------------------------------+ datetime BuildDateTime(int hour) { MqlDateTime dt; dt.year = 2026; dt.mon = 1; dt.day = 15; dt.hour = hour; dt.min = 30; dt.sec = 0; return(::StructToTime(dt)); }
OnStart() runs eight assertions: the four covering session classification, the win rate and net P&L formulas from the same six-trade fixture, the average hold time formula, and a final group verifying that ComputeTotals() correctly folds that same six-trade fixture into an account-wide total.
//+------------------------------------------------------------------+ //| OnStart | //| Runs classification, aggregation, and hold time tests and | //| prints a final summary of pass and fail counts. | //+------------------------------------------------------------------+ void OnStart(void) { CSessionClassifier classifier; classifier.LoadDefaults(); //--- test 1: 14:30 UTC falls in the London/New York overlap, expect London ENUM_TRADING_SESSION overlap_result = classifier.Classify(BuildDateTime(14)); ASSERT(overlap_result == SESSION_LONDON, "14:30 UTC overlap classifies as SESSION_LONDON"); //--- test 2: 00:30 UTC falls inside the midnight-crossing Sydney boundary ENUM_TRADING_SESSION sydney_result = classifier.Classify(BuildDateTime(0)); ASSERT(sydney_result == SESSION_SYDNEY, "00:30 UTC classifies as SESSION_SYDNEY across midnight"); //--- test 3: 03:00 UTC also falls inside the Sydney boundary ENUM_TRADING_SESSION sydney_result_2 = classifier.Classify(BuildDateTime(3)); ASSERT(sydney_result_2 == SESSION_SYDNEY, "03:00 UTC classifies as SESSION_SYDNEY across midnight"); //--- test 4: 10:00 UTC falls inside the London-only window, before overlap ENUM_TRADING_SESSION london_only = classifier.Classify(BuildDateTime(10)); ASSERT(london_only == SESSION_LONDON, "10:00 UTC classifies as SESSION_LONDON before the overlap"); //--- build a set of deal records to exercise the aggregator CDealRecord records[6]; for(int i = 0; i < 6; i++) records[i].session_type = SESSION_LONDON; records[0].profit = 100.0; records[0].open_time = BuildDateTime(8); records[0].close_time = BuildDateTime(8) + 1800; records[1].profit = 50.0; records[1].open_time = BuildDateTime(8); records[1].close_time = BuildDateTime(8) + 3600; records[2].profit = 75.0; records[2].open_time = BuildDateTime(8); records[2].close_time = BuildDateTime(8) + 5400; records[3].profit = -20.0; records[3].open_time = BuildDateTime(8); records[3].close_time = BuildDateTime(8); records[4].profit = -30.0; records[4].open_time = BuildDateTime(8); records[4].close_time = BuildDateTime(8); records[5].profit = 25.0; records[5].open_time = BuildDateTime(8); records[5].close_time = BuildDateTime(8); //--- test 5: win rate for 4 wins out of 6 deals should be 66.7% CSessionAggregator aggregator; CSessionMetrics metrics[]; int metrics_count = aggregator.Aggregate(records, 6, metrics); double win_rate = ((double)metrics[SESSION_LONDON].win_count / (double)metrics[SESSION_LONDON].trade_count) * 100.0; ASSERT(::MathAbs(win_rate - 66.7) < 0.05, "win rate for 4 of 6 winning deals rounds to 66.7%"); //--- test 6: net P&L for the same six deals should sum to 200.0 ASSERT(::MathAbs(metrics[SESSION_LONDON].net_pnl - 200.0) < 0.001, "net P&L across the six test deals sums to 200.0"); //--- test 7: average hold time for three deals of 1800, 3600, 5400 seconds CDealRecord hold_records[3]; hold_records[0].session_type = SESSION_TOKYO; hold_records[0].profit = 10.0; hold_records[0].open_time = BuildDateTime(1); hold_records[0].close_time = BuildDateTime(1) + 1800; hold_records[1].session_type = SESSION_TOKYO; hold_records[1].profit = 10.0; hold_records[1].open_time = BuildDateTime(1); hold_records[1].close_time = BuildDateTime(1) + 3600; hold_records[2].session_type = SESSION_TOKYO; hold_records[2].profit = 10.0; hold_records[2].open_time = BuildDateTime(1); hold_records[2].close_time = BuildDateTime(1) + 5400; CSessionMetrics hold_metrics[]; aggregator.Aggregate(hold_records, 3, hold_metrics); ASSERT(::MathAbs(hold_metrics[SESSION_TOKYO].average_hold_time - 3600.0) < 0.001, "average hold time for 1800, 3600, 5400 seconds computes to 3600.0"); //--- print the final summary of pass and fail counts ::Print("TestSessionAnalytics: ", g_pass_count, " passed, ", g_fail_count, " failed"); }

Dashboard mockup: four session bars extend left (red, loss) or right (green, profit) from the center zero line, with a divider and summary row below showing the account-wide trade count, win rate, and net P&L, exactly as CSessionBarChart::Draw() renders it.
Section 9 — Extending the Dashboard
Once the core dashboard is functional, the foundational classes can be easily extended to support more advanced analysis. For instance, deals executed outside the four major boundaries currently default to SESSION_UNKNOWN. To actively track these quiet overnight periods, you can introduce a dedicated "Off-Hours" session. This requires extending ENUM_TRADING_SESSION with a new value, updating the CSessionAggregator count, and adding a final catch-all boundary in CSessionClassifier::Classify() to capture any remaining hours rather than letting them fall through.
Another valuable extension is isolating performance by symbol to determine which specific instruments drive a session's profitability. The simplest implementation adds a symbol filter directly inside CDealHistoryReader::Read(), allowing the user to pass in _Symbol to ignore non-matching deals and view one instrument in isolation. For a more comprehensive breakdown across the entire account, you could introduce a CSymbolSessionMetrics struct and add a new aggregation method to group data by both session and symbol, reusing the existing scanning logic.
To facilitate external analysis or advanced charting, exporting the computed metrics to a CSV file is a natural companion to the Experts tab table. This can be achieved by using FileOpen() with the FILE_WRITE and FILE_CSV flags. A straightforward loop would then iterate through the session rows, using FileWrite() to output the exact same five columns generated by the CSessionTablePrinter before securely closing the handle with FileClose().
Finally, adding a secondary date input would allow traders to compare historical periods side by side, such as the current month versus the previous month. Implementing this involves running the read, classify, and aggregate pipeline twice—once for each date range. The CSessionBarChart::Draw() method could then be updated to accept a second metrics array, rendering paired bars on the canvas. Using lighter shades of the existing red and green ensures the two periods are easily distinguishable without cluttering the interface with unrelated colors.
Section 10: Limitations
The dashboard uses fixed UTC session boundaries and does not account for brokers whose server clocks shift for daylight saving time. The classifier uses the UTC hour extracted from the deal's close time and assumes that timestamp is already in UTC. Under that assumption, broker daylight-saving shifts do not affect results. If your broker reports deal times differently, verify the time basis before using the classification.
If the open time cannot be recovered (for example, when the position was opened before the selected date range), the code falls back to using the close time as the open time. This produces a zero hold time for that record. This understates the average hold time for any session containing such deals, and a trader working with data near the edge of a lookback window should be aware that widening the lookback range further back in time will typically produce more accurate hold time figures for the earliest deals in the original range.
Because sessions overlap, a deal closed at 14:00 UTC can belong to either London or New York. The classifier resolves this by assigning the deal to whichever session appears first in the boundary array, which in the default configuration is London. This is a simplification chosen for predictability rather than an attempt to model which market was truly more responsible for the deal's outcome, and a trader who wants the opposite resolution can simply reorder the boundary array in LoadDefaults().
CDealHistoryReader::Read() applies no filter based on symbol, so every closed deal in the queried date range is included in the metrics, regardless of which instrument it was traded on. A trader running the script from different chart symbols will see identical output every time, since the underlying query never compares DEAL_SYMBOL against the chart's own symbol. This is by design for this version of the dashboard, which is meant to give an account-wide view of session performance, but it means a trader who wants to isolate one instrument's session behavior needs to add that filtering themselves, as described in Section 9.
Finally, the P&L label uses a fixed right-margin offset. It does not measure the rendered text width. A short value like a small win or loss sits comfortably inside the panel, while a longer value with more digits can run close to, or in some cases past, the panel's right border, since the offset does not adjust for how wide the formatted string actually is.
Conclusion
What you get from the article is a complete, runnable pipeline and clear next steps. The distributed components include a history reader, a UTC session classifier, an aggregator that computes net P&L, win rates, trade counts and average hold times, a CCanvas bar chart renderer, and a table printer — plus two scripts you can run immediately in the terminal:
- SessionDashboard.mq5 — reads history for the input lookback period, classifies and aggregates trades, draws the session bar chart (with an account‑wide summary row), and prints the same data to Experts.
- TestSessionAnalytics.mq5 — a verification suite that exercises boundary hours, the midnight‑wrapping Sydney session, aggregation sums, and average hold time formulas and reports explicit PASS/FAIL results.
The code is intentionally modular so you can extend it quickly: add an Off‑Hours session, filter by symbol, export CSV, or compare two date ranges by running the pipeline twice and adapting the renderer. Important caveats remain: the classifier uses deal close times in UTC (confirm your broker's time basis), recovered open times fall back to close time if the opening deal is outside the selected range (which underestimates average hold time), and overlapping sessions are resolved by boundary order (default: London before New York). To get started, run TestSessionAnalytics.mq5 to validate the logic for your environment, then run SessionDashboard.mq5 with your chosen lookback and panel settings; tweak session boundaries or add symbol filters as required.
Programs used in the article:
| # | Name | Type | Description |
|---|---|---|---|
| 1 | SessionEnums.mqh | Include File | Defines ENUM_TRADING_SESSION and the CSessionBoundary, CSessionMetrics, and CDealRecord structs |
| 2 | SessionClassifier.mqh | Include File | CSessionClassifier class: classifies a UTC datetime into a trading session |
| 3 | DealHistoryReader.mqh | Include File | CDealHistoryReader class: reads closed deal records from terminal history across all symbols |
| 4 | SessionAggregator.mqh | Include File | CSessionAggregator class: groups deal records by session, computes metrics, and folds them into account-wide totals |
| 5 | SessionBarChart.mqh | Include File | CSessionBarChart class: renders a persistent CCanvas horizontal bar chart with a summary row |
| 6 | SessionTablePrinter.mqh | Include File | CSessionTablePrinter class: prints the session summary table to the Experts tab |
| 7 | SessionDashboard.mq5 | Script | Main script: wires all components, reads history, classifies deals, aggregates, computes totals, and renders |
| 8 | TestSessionAnalytics.mq5 | Script | Verification script covering classification boundaries, aggregation correctness, hold time, and account-wide totals |
| 9 | SessionDashboard.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
Defining your Edge (Part 6): Harnessing Fourier Transform and a Spiking Neural Network in an Expert Advisor
Features of Experts Advisors
From Basic to Intermediate: Operator Overloading (II)
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use