Measuring Spread Costs Before a Market Order in MQL5
Introduction
Before placing a planned Buy or Sell, how much of the expected favorable price move would remain after spread? The live Bid and Ask describe current entry conditions, but the estimate also depends on the spread assumed at exit.
In this article, we will build Trading Cost Planner, an MQL5 Expert Advisor for the chart's current symbol. It compares two spread-only estimates in points: one assumes the current spread at exit; the other uses the greater of the current spread and a percentile calculated from retrieved historical samples. Both estimates use the planned favorable movement of the midpoint between Bid and Ask. They are scenario calculations, not predictions of profit, and exclude commission, swap, and slippage.
The collapsible panel distinguishes valid estimates from unavailable data and shows execution readiness separately. An optional button can request a market order after click-time checks. That action is independent of the estimates: the EA attaches no SL or TP and does not manage the exit.
Defining the Trade Plan and Cost Model
The plan starts with a direction, a lot size, and an expected favorable move. The expected move describes a change in the market’s midpoint, halfway between Bid and Ask. It is measured in the chart symbol’s points.
A market Buy opens at the Ask and closes at the Bid. A market Sell opens at Bid and would close at Ask. Relative to the midpoint, entry contributes half the current spread and exit contributes half the assumed exit spread. The spread-only estimate in either direction is:

Suppose the expected move is 100 points and the current spread is 20 points. If the exit spread is also 20 points, the projected result is 100-(20+20)/2=80 points. If the exit spread widens to 40 points, the result falls to 100-(20+40)/2=70 points. These are results under stated spread assumptions, not predicted trade profits.
Trade volume controls order size; it does not scale the pre-trade estimates in points. The advisory threshold labels each valid estimate and does not permit or prevent execution. The calculation includes spread only. Commission, swap, slippage, and monetary profit are outside this estimate.
The EA uses these inputs:
| Input | Purpose in the EA |
|---|---|
| InpDirection | Selects a planned market Buy or Sell |
| InpVolume | Sets market-order volume in lots; does not scale point estimates. |
| InpTargetMovePoints | Sets the expected favorable midpoint move in points. |
| InpMinimumAfterSpreadPoints | Sets the advisory comparison threshold in points. |
| InpHistoryHours | Sets the recent tick-history window. |
| InpStressPercentile | Selects a high spread from retrieved valid samples. |
| InpMinimumHistorySamples | Sets the sample count required for a historical result. |
| InpQuoteMaxAgeSeconds | Sets the maximum acceptable age of a live quote. |
| InpMaxDeviationPoints | Sets the requested market-order deviation in points. |
| InpPanelX, InpPanelY | Position the chart panel. |
InpMaxDeviationPoints concerns order execution and does not change either projection. The expected move is used for calculation; it does not place a take-profit order.
In MetaEditor, create a new Expert Advisor named TradingCostPlanner. Open the generated TradingCostPlanner.mq5 file, delete all of its generated contents, and replace them with the complete starting source below. The empty event handlers make this first checkpoint compile; later sections will replace them as each feature is added.
//+------------------------------------------------------------------+ //| TradingCostPlanner.mq5 | //| Copyright 2025, MetaQuotes Ltd. Developer is Chacha Ian | //| https://www.mql5.com/en/users/chachaian | //+------------------------------------------------------------------+ #property copyright "Copyright 2025, MetaQuotes Ltd. Developer is Chacha Ian" #property link "https://www.mql5.com/en/users/chachaian" #property version "1.00" #property strict //+------------------------------------------------------------------+ //| Trade direction | //+------------------------------------------------------------------+ enum ENUM_PLAN_DIRECTION { PLAN_BUY = 0, // Plan a market Buy PLAN_SELL = 1 // Plan a market Sell }; //--- Proposed trade input group "Proposed trade" input ENUM_PLAN_DIRECTION InpDirection = PLAN_BUY; // Trade direction input double InpVolume = 0.01; // Order volume in lots; estimates remain in points input double InpTargetMovePoints = 100.0; // Expected favorable midpoint move in points input double InpMinimumAfterSpreadPoints = 0.0; // Advisory threshold after spread in points //--- Historical spread scenario input group "Historical spread scenario" input int InpHistoryHours = 24; // Recent tick history in hours input int InpStressPercentile = 90; // Historical spread percentile input int InpMinimumHistorySamples = 30; // Minimum valid spread samples //--- Quote and execution settings input group "Quote and execution" input int InpQuoteMaxAgeSeconds = 10; // Maximum usable quote age in seconds input int InpMaxDeviationPoints = 20; // Requested order deviation in points //--- Panel position input group "Panel position" input int InpPanelX = 15; // Panel distance from the left input int InpPanelY = 145; // Panel distance from the top //+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { return(INIT_SUCCEEDED); } //+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { } //+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { } //+------------------------------------------------------------------+ //| Timer function | //+------------------------------------------------------------------+ void OnTimer() { } //+------------------------------------------------------------------+ //| Chart event function | //+------------------------------------------------------------------+ void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam) { } //+------------------------------------------------------------------+
Compile the file in MetaEditor and check the compilation results. Resolve any errors or warnings before proceeding. The EA does not calculate or trade yet; this checkpoint establishes the inputs and source structure required by the following sections.
Establishing the EA and Reading a Live Quote
The calculation needs a valid plan and a usable quote before it can display a spread. The EA therefore validates its inputs and reads the chart symbol’s trading properties during initialization. If either check fails, initialization stops and the Experts tab receives a specific reason.
Add the following state declarations after the input groups and before the event handlers. They hold the symbol properties and the latest quote; g_quoteValid tells later calculations whether those quote values can be used.
//--- Symbol properties and runtime state double g_point = 0.0; // Price size of one point for the chart symbol double g_volumeMin = 0.0; // Smallest permitted trade volume double g_volumeMax = 0.0; // Largest permitted trade volume double g_volumeStep = 0.0; // Permitted volume increment bool g_timerActive = false; // Whether the timer started successfully //--- Current quote state double g_bid = 0.0; // Bid from the last valid quote double g_ask = 0.0; // Ask from the last valid quote double g_spreadPoints = 0.0; // Current Bid/Ask spread in points datetime g_quoteTime = 0; // Server time of the latest symbol quote bool g_quoteValid = false; // Whether the quote is usable string g_quoteStatus = "WAITING FOR QUOTE"; // Current quote availability
ValidateInputs() checks the settings that define the plan, history request, quote age, and panel position. Its job is to reject an unusable configuration before the timer or chart readout starts. Place the complete function after the state declarations.
//+------------------------------------------------------------------+ //| Validate the configured inputs | //+------------------------------------------------------------------+ bool ValidateInputs() { //--- Accept only the two directions supported by the calculation //--- and the manual market execution path. if(InpDirection != PLAN_BUY && InpDirection != PLAN_SELL) { Print("Trading Cost Planner: Invalid trade direction."); return(false); } //--- Volume must be a finite, positive number. Broker-specific volume //--- limits and steps are checked separately against symbol properties. if(!MathIsValidNumber(InpVolume) || InpVolume <= 0.0) { Print("Trading Cost Planner: Trade volume must be greater than zero."); return(false); } //--- The expected move is a positive price distance in points. //--- A zero or invalid value cannot define a meaningful trade plan. if(!MathIsValidNumber(InpTargetMovePoints) || InpTargetMovePoints <= 0.0) { Print("Trading Cost Planner: Expected move must be greater than zero points."); return(false); } //--- The minimum is a comparison threshold for projected results. //--- Zero is permitted, but a negative or invalid threshold is not. if(!MathIsValidNumber(InpMinimumAfterSpreadPoints) || InpMinimumAfterSpreadPoints < 0.0) { Print("Trading Cost Planner: Minimum result after spread cannot be negative."); return(false); } //--- Keep the history request within a defined interval of one hour //--- to seven days before loading and sampling tick data. if(InpHistoryHours < 1 || InpHistoryHours > 168) { Print("Trading Cost Planner: History interval must be 1 to 168 hours."); return(false); } //--- Require a high percentile below 100 so the historical scenario //--- represents a selected upper-range spread rather than an extreme maximum. if(InpStressPercentile <= 50 || InpStressPercentile >= 100) { Print("Trading Cost Planner: Stress percentile must be between 51 and 99."); return(false); } //--- A percentile needs valid observations. The upper bound also //--- prevents an impractical sample requirement from being configured. if(InpMinimumHistorySamples < 1 || InpMinimumHistorySamples > 100000) { Print("Trading Cost Planner: Minimum history samples must be 1 to 100000."); return(false); } //--- Bound the allowed quote age so stale Bid/Ask values cannot remain //--- eligible for a calculation or a manual execution request indefinitely. if(InpQuoteMaxAgeSeconds < 1 || InpQuoteMaxAgeSeconds > 3600) { Print("Trading Cost Planner: Maximum quote age must be 1 to 3600 seconds."); return(false); } //--- Deviation and chart offsets are distances; negative values have //--- no valid meaning for the order request or panel placement. if(InpMaxDeviationPoints < 0 || InpPanelX < 0 || InpPanelY < 0) { Print("Trading Cost Planner: Deviation and panel distances cannot be negative."); return(false); } return(true); }
The input volume must comply with the chart symbol's trading specifications. InitializeSymbolProperties() retrieves the point size, minimum volume, maximum volume, and volume step. It rejects volumes outside the permitted range and checks step alignment relative to the minimum permitted volume. A small tolerance accounts for floating-point rounding. The function does not silently adjust the requested lot size. Place it immediately after ValidateInputs().
//+------------------------------------------------------------------+ //| Read and validate the chart symbol's trading properties | //+------------------------------------------------------------------+ bool InitializeSymbolProperties() { //--- Read the point size used to convert price differences into //--- the symbol's points throughout the planner. ResetLastError(); if(!SymbolInfoDouble(_Symbol, SYMBOL_POINT, g_point)) { PrintFormat("Trading Cost Planner: Cannot read point size for %s. Error %d.", _Symbol, GetLastError()); return(false); } //--- Obtain the broker's minimum, maximum, and step for trade volume. //--- Report each failed property read separately to identify the cause. ResetLastError(); if(!SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN, g_volumeMin)) { PrintFormat("Trading Cost Planner: Cannot read minimum volume for %s. Error %d.", _Symbol, GetLastError()); return(false); } ResetLastError(); if(!SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX, g_volumeMax)) { PrintFormat("Trading Cost Planner: Cannot read maximum volume for %s. Error %d.", _Symbol, GetLastError()); return(false); } ResetLastError(); if(!SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP, g_volumeStep)) { PrintFormat("Trading Cost Planner: Cannot read volume step for %s. Error %d.", _Symbol, GetLastError()); return(false); } //--- Property reads can succeed while returning unusable values. //--- Stop initialization if the point size or volume rules are invalid. if(!MathIsValidNumber(g_point) || g_point <= 0.0 || !MathIsValidNumber(g_volumeMin) || g_volumeMin <= 0.0 || !MathIsValidNumber(g_volumeMax) || g_volumeMax < g_volumeMin || !MathIsValidNumber(g_volumeStep) || g_volumeStep <= 0.0) { PrintFormat("Trading Cost Planner: Invalid trading properties for %s.", _Symbol); return(false); } //--- Preserve the lot size selected by the trader. Reject a volume //--- outside the broker's range instead of adjusting it silently. if(InpVolume < g_volumeMin - 1.0e-8 || InpVolume > g_volumeMax + 1.0e-8) { PrintFormat("Trading Cost Planner: Volume %.8f is outside the permitted range " "%.8f to %.8f lots on %s.", InpVolume, g_volumeMin, g_volumeMax, _Symbol); return(false); } //--- Check step alignment relative to the minimum permitted volume. //--- The tolerance accounts for floating-point rounding near an integer. double steps = (InpVolume - g_volumeMin) / g_volumeStep; if(MathAbs(steps - MathRound(steps)) > 1.0e-7) { PrintFormat("Trading Cost Planner: Volume %.8f does not match the %.8f-lot " "step from the minimum volume %.8f on %s.", InpVolume, g_volumeStep, g_volumeMin, _Symbol); return(false); } return(true); }
ReadCurrentQuote() reads Bid and Ask from a single MqlTick snapshot and calculates the spread as (Ask − Bid)/point size. It rejects invalid prices and stale quotes, while accepting equal positive Bid and Ask prices as a zero spread. Clearing the previous snapshot before each attempt prevents an old spread from remaining marked as current when validation fails.
Place the following function immediately after InitializeSymbolProperties().
//+------------------------------------------------------------------+ //| Read a fresh Bid/Ask quote for the chart symbol | //+------------------------------------------------------------------+ bool ReadCurrentQuote() { //--- Clear the previous snapshot before attempting another read. //--- A failed check must not leave old prices marked as current. g_quoteValid = false; g_bid = 0.0; g_ask = 0.0; g_spreadPoints = 0.0; g_quoteTime = 0; //--- Validate the conversion unit before dividing a price difference. if(!MathIsValidNumber(g_point) || g_point <= 0.0) { g_quoteStatus = "INVALID POINT SIZE"; return(false); } //--- Obtain Bid, Ask, and quote time from one coherent tick snapshot. MqlTick tick = {}; ResetLastError(); if(!SymbolInfoTick(_Symbol, tick)) { g_quoteStatus = StringFormat("QUOTE UNAVAILABLE: error %d", GetLastError()); return(false); } //--- Equal Bid and Ask are permitted. Reject unusable prices, //--- a reversed quote, or a missing timestamp. if(!MathIsValidNumber(tick.bid) || !MathIsValidNumber(tick.ask) || tick.bid <= 0.0 || tick.ask <= 0.0 || tick.ask < tick.bid || tick.time <= 0) { g_quoteStatus = "INVALID BID/ASK QUOTE"; return(false); } //--- Use advancing server time so timer updates can detect an old //--- quote even when the chart symbol receives no new ticks. datetime serverTime = TimeTradeServer(); if(serverTime <= 0) { g_quoteStatus = "SERVER TIME UNAVAILABLE"; return(false); } long quoteAge = (long)(serverTime - tick.time); if(quoteAge < 0) { g_quoteStatus = "QUOTE TIME IS AHEAD OF SERVER TIME"; return(false); } if(quoteAge > InpQuoteMaxAgeSeconds) { g_quoteStatus = StringFormat("STALE QUOTE: %I64d seconds old", quoteAge); return(false); } //--- Express the spread in the symbol's points. Zero is valid, //--- matching the existing historical-sample and projection policies. double spreadPoints = (tick.ask - tick.bid) / g_point; if(!MathIsValidNumber(spreadPoints) || spreadPoints < 0.0) { g_quoteStatus = "INVALID SPREAD"; return(false); } //--- Publish the snapshot only after all validation succeeds. g_bid = tick.bid; g_ask = tick.ask; g_spreadPoints = spreadPoints; g_quoteTime = tick.time; g_quoteValid = true; g_quoteStatus = "CURRENT QUOTE"; return(true); }
Before building the panel, use Comment() as a temporary readout. UpdateQuoteReadout() displays the prices and spread only when ReadCurrentQuote() accepts the snapshot; otherwise, it displays the failure status. Place it immediately after ReadCurrentQuote().
//+------------------------------------------------------------------+ //| Display the current quote status on the chart | //+------------------------------------------------------------------+ void UpdateQuoteReadout() { //--- Refresh the stored quote before drawing anything. The read //--- invalidates earlier prices if the latest quote cannot be used. ReadCurrentQuote(); //--- Show the failure reason without displaying an old Bid, Ask, //--- or spread that might be mistaken for current market data. if(!g_quoteValid) { Comment("Trading Cost Planner\n", "Quote: ", g_quoteStatus, "\n", "Bid/Ask and spread: unavailable"); return; } //--- Display prices using the symbol's digits and the spread in //--- points. This temporary readout supports direct quote checks //--- before the chart panel is introduced. Comment("Trading Cost Planner\n", "Quote: ", g_quoteStatus, "\n", "Bid: ", DoubleToString(g_bid, _Digits), "\n", "Ask: ", DoubleToString(g_ask, _Digits), "\n", "Current spread: ", DoubleToString(g_spreadPoints, 1), " points"); }
Replace the existing OnInit(), OnDeinit(), OnTick(), and OnTimer() functions with the complete blocks below. Keep exactly one definition of each. Initialization performs validation and starts a five-second timer. Ticks refresh the readout promptly; the timer also refreshes it when no new tick arrives, allowing quote age to advance to STALE QUOTE. Deinitialization stops the timer and clears the temporary chart text. Leave OnChartEvent() unchanged.
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- Reject an unusable plan or symbol before starting updates. //--- This also prevents the readout from using an invalid point size. if(!ValidateInputs() || !InitializeSymbolProperties()) return(INIT_PARAMETERS_INCORRECT); //--- The timer must keep checking quote age when no new tick arrives. //--- Stop initialization if periodic updates cannot be started. ResetLastError(); if(!EventSetTimer(5)) { PrintFormat("Trading Cost Planner: Could not start the timer. Error %d.", GetLastError()); return(INIT_FAILED); } //--- Record that the timer started so deinitialization can stop it, //--- then populate the temporary readout without waiting for a tick. g_timerActive = true; UpdateQuoteReadout(); return(INIT_SUCCEEDED); } //+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { //--- Stop only a timer that was started successfully. Clear the //--- temporary chart comment so it does not outlive this EA. if(g_timerActive) { EventKillTimer(); g_timerActive = false; } Comment(""); } //+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- Update the displayed quote promptly when the chart symbol //--- receives a new market tick. UpdateQuoteReadout(); } //+------------------------------------------------------------------+ //| Timer function | //+------------------------------------------------------------------+ void OnTimer() { //--- Recheck the quote between ticks. Time can advance without a //--- new price, so this call can change a valid quote to STALE QUOTE. UpdateQuoteReadout(); }
Compile and attach the EA to a chart. Compare the displayed Bid, Ask, and spread with the live quote. During a quiet period, the readout should change to STALE QUOTE after InpQuoteMaxAgeSeconds, with prices and spread marked unavailable. An invalid input or unsupported volume should stop initialization and print a specific reason in the Experts tab.
Building the Historical Spread Scenario
The live quote supplies one current spread. A planned exit may occur under different spread conditions, so the EA also measures a high spread observed in recent tick history. The selected percentile describes the retrieved observations, not a prediction or a guaranteed upper bound on the future spread.
Using the nearest-rank method, the percentile rank is ceil(N × P/100), and the sorted-array index is rank − 1, where N is the valid sample count and P is the configured percentile. P90 describes the level at or below which at least 90% of the retained samples fall, not a 90% probability about a future exit. The EA keeps the latest valid spread per sampled second; a zero spread is allowed. The 20,000-tick cap can shorten retrieval on active symbols. The first and last valid sample times, their span, and the sample count expose the actual sample interval. The span does not imply uninterrupted sampling or a time-weighted spread distribution.
Add the following declarations in TradingCostPlanner.mq5 immediately after the current quote state declarations. They hold the sampled result, its availability, the sample count, and the time of the last refresh attempt.
//--- Historical spread settings and state #define HISTORY_TICK_LIMIT 20000 #define HISTORY_REFRESH_SECONDS 60 double g_historicalSpreadPoints = 0.0; // Selected historical spread in points int g_historySampleCount = 0; // Valid one-second spread samples bool g_historyAvailable = false; // Whether the percentile is supported datetime g_lastHistoryAttempt = 0; // Time of the latest history request string g_historyStatus = "WAITING"; // History availability and coverage datetime g_historyFirstSampleTime = 0; // First valid sample, server time datetime g_historyLastSampleTime = 0; // Last valid sample, server time long g_historySpanSeconds = 0; // Time between first and last samples bool g_historyTickLimitHit = false; // Tick cap prevented full requested retrieval
RefreshHistoricalSpread() owns the history calculation. It clears the previous result before requesting ticks, so a failed refresh cannot leave an old percentile marked as current. It reports insufficient samples explicitly and records when the 20,000-tick limit restricts coverage. Place the complete function immediately after ReadCurrentQuote() and before UpdateQuoteReadout().
//+------------------------------------------------------------------+ //| Measure historical spread and record actual sample boundaries | //+------------------------------------------------------------------+ bool RefreshHistoricalSpread() { //--- Clear both the percentile and its coverage metadata. //--- A failed refresh must not leave an old interval looking current. g_historyAvailable = false; g_historicalSpreadPoints = 0.0; g_historySampleCount = 0; g_historyFirstSampleTime = 0; g_historyLastSampleTime = 0; g_historySpanSeconds = 0; g_historyTickLimitHit = false; if(!MathIsValidNumber(g_point) || g_point <= 0.0) { g_historyStatus = "INVALID POINT SIZE"; return(false); } datetime serverTime = TimeTradeServer(); if(serverTime <= 0) { g_historyStatus = "SERVER TIME UNAVAILABLE"; return(false); } datetime windowStart = serverTime - InpHistoryHours * 3600; MqlTick ticks[]; //--- Preserve the existing request: latest information ticks, //--- capped at HISTORY_TICK_LIMIT. ResetLastError(); int copied = CopyTicks(_Symbol, ticks, COPY_TICKS_INFO, 0, HISTORY_TICK_LIMIT); int copyError = GetLastError(); if(copied <= 0 || copyError != 0) { g_historyStatus = StringFormat("TICK HISTORY UNAVAILABLE: error %d", copyError); return(false); } //--- A full response beginning after the requested start indicates //--- that the tick cap restricted the retrieved interval. g_historyTickLimitHit = (copied == HISTORY_TICK_LIMIT && ticks[0].time > windowStart); double spreads[]; if(ArrayResize(spreads, copied) != copied) { g_historyStatus = "CANNOT ALLOCATE SPREAD SAMPLES"; return(false); } int validCount = 0; datetime sampleTime = 0; //--- Preserve one latest valid quote per sampled second. //--- Seconds without valid ticks do not generate synthetic samples. for(int i = 0; i < copied; i++) { if(ticks[i].time < windowStart || ticks[i].time > serverTime) continue; if(!MathIsValidNumber(ticks[i].bid) || !MathIsValidNumber(ticks[i].ask) || ticks[i].bid <= 0.0 || ticks[i].ask <= 0.0 || ticks[i].ask < ticks[i].bid) continue; double spreadPoints = (ticks[i].ask - ticks[i].bid) / g_point; if(!MathIsValidNumber(spreadPoints) || spreadPoints < 0.0) continue; //--- Replace the observation within the same second without //--- increasing the sample count or changing its second timestamp. if(validCount > 0 && ticks[i].time == sampleTime) { spreads[validCount - 1] = spreadPoints; continue; } //--- Record boundaries from accepted samples, not merely from //--- the first and last raw ticks returned by CopyTicks(). if(validCount == 0) g_historyFirstSampleTime = ticks[i].time; g_historyLastSampleTime = ticks[i].time; spreads[validCount] = spreadPoints; validCount++; sampleTime = ticks[i].time; } g_historySampleCount = validCount; //--- One valid sample has a zero span. No samples leave both //--- timestamps unset, allowing the display to show unavailable. if(validCount > 0) g_historySpanSeconds = (long)(g_historyLastSampleTime - g_historyFirstSampleTime); //--- Keep the sample metadata even when the percentile cannot //--- be supported by the configured minimum count. if(validCount < InpMinimumHistorySamples) { g_historyStatus = StringFormat("INSUFFICIENT HISTORY: %d/%d samples", validCount, InpMinimumHistorySamples); return(false); } if(ArrayResize(spreads, validCount) != validCount) { g_historyStatus = "CANNOT RESIZE SPREAD SAMPLES"; return(false); } ResetLastError(); if(!ArraySort(spreads)) { g_historyStatus = StringFormat("CANNOT SORT SPREAD SAMPLES: error %d", GetLastError()); return(false); } //--- Preserve the nearest-rank percentile method. int rank = (int)MathCeil(validCount * (double)InpStressPercentile / 100.0) - 1; g_historicalSpreadPoints = spreads[rank]; g_historyAvailable = true; if(g_historyTickLimitHit) g_historyStatus = "READY: TICK CAP LIMITED RETRIEVAL"; else g_historyStatus = "READY"; return(true); }
Place the following duration-formatting helper immediately after RefreshHistoricalSpread().
//+------------------------------------------------------------------+ //| Format a sample span without rounding it to whole hours | //+------------------------------------------------------------------+ string FormatHistorySpan(const long seconds) { long hours = seconds / 3600; long minutes = (seconds % 3600) / 60; long rest = seconds % 60; return(StringFormat("%I64dh %I64dm %I64ds", hours, minutes, rest)); }
The temporary readout now needs to distinguish a usable percentile from missing history. Replace the entire existing UpdateQuoteReadout() function with this version. It retains the live quote display and adds the percentile, valid sample count, and history status.
//+------------------------------------------------------------------+ //| Display the current spread and historical sample coverage | //+------------------------------------------------------------------+ void UpdateQuoteReadout() { //--- Refresh the live quote independently of the scheduled history read. ReadCurrentQuote(); string quoteText = "Bid/Ask and current spread: unavailable"; if(g_quoteValid) quoteText = "Bid: " + DoubleToString(g_bid, _Digits) + "\nAsk: " + DoubleToString(g_ask, _Digits) + "\nCurrent spread: " + DoubleToString(g_spreadPoints, 1) + " points"; string historyText = "Historical spread: unavailable"; if(g_historyAvailable) historyText = "Historical P" + IntegerToString(InpStressPercentile) + ": " + DoubleToString(g_historicalSpreadPoints, 1) + " points"; //--- Valid samples can have usable boundaries even when their count //--- is insufficient to support the percentile. string coverageText = "First sample: unavailable\n" "Last sample: unavailable\n" "Sample span: unavailable"; if(g_historySampleCount > 0) coverageText = "First sample: " + TimeToString(g_historyFirstSampleTime, TIME_DATE | TIME_SECONDS) + " (server)\nLast sample: " + TimeToString(g_historyLastSampleTime, TIME_DATE | TIME_SECONDS) + " (server)\nSample span: " + FormatHistorySpan(g_historySpanSeconds); //--- Report restricted retrieval separately from percentile validity. string capText = (g_historyTickLimitHit ? "Tick cap restricted retrieval" : "Tick cap restriction not detected"); Comment("Trading Cost Planner\n", "Quote: ", g_quoteStatus, "\n", quoteText, "\n", historyText, "\n", "Valid samples: ", g_historySampleCount, "\n", "History: ", g_historyStatus, "\n", "Requested lookback: ", InpHistoryHours, " hours\n", coverageText, "\n", capText); }
Tick events can continue refreshing the live quote without repeatedly loading history. OnTimer() attempts the history refresh at most once every 60 seconds, including when an attempt fails, and then updates the readout. Replace the complete existing OnTimer() function with this block; leave OnTick() unchanged.
//+------------------------------------------------------------------+ //| Timer function | //+------------------------------------------------------------------+ void OnTimer() { //--- Use server time for the history refresh interval. If server //--- time is unavailable, leave history unchanged on this cycle. datetime serverTime = TimeTradeServer(); //--- Attempt a history refresh at most once every configured interval. //--- Record the attempt before calling the loader so unavailable //--- history does not cause a new request on every five-second timer event. if(serverTime > 0 && (g_lastHistoryAttempt == 0 || serverTime - g_lastHistoryAttempt >= HISTORY_REFRESH_SECONDS)) { g_lastHistoryAttempt = serverTime; RefreshHistoricalSpread(); } //--- Refresh the chart readout on every timer event. This also checks //--- whether the last live quote has become stale without a new tick. UpdateQuoteReadout(); }
Compile the EA and attach it to an active chart. After the first scheduled history refresh, check the percentile, sample count, first and last sample times, and sample span. Compare the span with the requested lookback. Increase InpMinimumHistorySamples above the available count: the percentile should become unavailable, while boundaries remain visible when valid samples exist. With no valid samples, the boundaries and span should also read unavailable.
Calculating the Two Projected Results
The current quote and historical percentile provide two assumptions for the spread at exit. The current-spread estimate assumes it remains at its current width. The historical-scenario estimate uses the greater of the current spread and the historical percentile. Both apply the same expected favorable midpoint move.
The entry price comes from the executable quote side: Ask for a Buy and Bid for a Sell. CalculateProjectedResult() constructs the assumed exit price after the midpoint move and measures the entry-to-exit difference in points. Add the following declarations immediately after the historical spread state declarations in TradingCostPlanner.mq5:
//--- Planned-move calculation state double g_currentResultPoints = 0.0; // Estimate assuming current spread at exit double g_stressResultPoints = 0.0; // Estimate using max(current spread, percentile) double g_stressSpreadPoints = 0.0; // Assumed exit spread for the historical scenario bool g_currentResultValid = false; // Whether the current-spread estimate is valid bool g_stressResultValid = false; // Whether the historical-scenario estimate is valid
Place the following function immediately after RefreshHistoricalSpread():
//+------------------------------------------------------------------+ //| Project the result for an assumed exit spread | //+------------------------------------------------------------------+ bool CalculateProjectedResult(const double exitSpreadPoints, double &resultPoints) { //--- Clear the output so a failed calculation cannot leave an //--- estimate from an earlier call in the caller's variable. resultPoints = 0.0; //--- Require a usable quote, a nonnegative assumed exit spread, //--- and a positive point size before constructing prices. if(!g_quoteValid || !MathIsValidNumber(exitSpreadPoints) || exitSpreadPoints < 0.0 || g_point <= 0.0) return(false); //--- Convert point distances to prices. The target distance shifts //--- the midpoint favorably in the selected trade direction. double midpoint = (g_bid + g_ask) / 2.0; double targetDistance = InpTargetMovePoints * g_point; double exitSpread = exitSpreadPoints * g_point; double openingPrice = 0.0; double closingPrice = 0.0; //--- A Buy enters at Ask and exits at the assumed Bid, which is //--- half the exit spread below the shifted midpoint. if(InpDirection == PLAN_BUY) { openingPrice = g_ask; closingPrice = midpoint + targetDistance - exitSpread / 2.0; resultPoints = (closingPrice - openingPrice) / g_point; } //--- A Sell enters at Bid and exits at the assumed Ask, which is //--- half the exit spread above the shifted midpoint. else if(InpDirection == PLAN_SELL) { openingPrice = g_bid; closingPrice = midpoint - targetDistance + exitSpread / 2.0; resultPoints = (openingPrice - closingPrice) / g_point; } else return(false); //--- A negative estimate is valid. Reject an unusable closing price //--- or a result that is not numerically valid. return(MathIsValidNumber(closingPrice) && closingPrice > 0.0 && MathIsValidNumber(resultPoints)); }
CalculatePlannedMove() selects the exit spread for each estimate. Using MathMax() prevents the historical scenario from assuming a narrower spread merely because the percentile is below the live value. In that case, both estimates are equal. Without sufficient history, the current-spread estimate can remain available while the historical-scenario estimate is unavailable.
Place the following function immediately after CalculateProjectedResult():
//+------------------------------------------------------------------+ //| Update the current-spread and historical-scenario estimates | //+------------------------------------------------------------------+ void CalculatePlannedMove() { //--- Invalidate previous estimates before evaluating the latest state. //--- Separate flags distinguish unavailable estimates from valid zero. g_currentResultValid = false; g_stressResultValid = false; g_currentResultPoints = 0.0; g_stressResultPoints = 0.0; g_stressSpreadPoints = 0.0; if(!g_quoteValid) return; //--- Use the live spread as the first assumed exit spread. g_currentResultValid = CalculateProjectedResult(g_spreadPoints, g_currentResultPoints); //--- Do not create a historical estimate without supported history. if(!g_historyAvailable) return; //--- Select an exit spread at least as wide as the current spread. g_stressSpreadPoints = MathMax(g_spreadPoints, g_historicalSpreadPoints); g_stressResultValid = CalculateProjectedResult(g_stressSpreadPoints, g_stressResultPoints); }
The temporary readout refreshes the quote before calculating either estimate. It compares valid estimates with InpMinimumAfterSpreadPoints; the comparison is advisory and does not submit or block an order.
Replace the entire existing UpdateQuoteReadout() function with the following version:
//+------------------------------------------------------------------+ //| Display projected estimates and historical sample coverage | //+------------------------------------------------------------------+ void UpdateQuoteReadout() { //--- Read the current quote before calculating either estimate. ReadCurrentQuote(); CalculatePlannedMove(); string quoteText = "Bid/Ask and current spread: unavailable"; if(g_quoteValid) quoteText = "Bid: " + DoubleToString(g_bid, _Digits) + "\nAsk: " + DoubleToString(g_ask, _Digits) + "\nCurrent spread: " + DoubleToString(g_spreadPoints, 1) + " points"; string historyText = "Historical spread: unavailable"; if(g_historyAvailable) historyText = "Historical P" + IntegerToString(InpStressPercentile) + ": " + DoubleToString(g_historicalSpreadPoints, 1) + " points"; //--- Preserve unavailable states instead of presenting unsupported //--- estimates as numeric zero. string currentText = "Current-spread estimate: unavailable"; if(g_currentResultValid) currentText = "Current-spread estimate: " + DoubleToString(g_currentResultPoints, 1) + " points | " + (g_currentResultPoints >= InpMinimumAfterSpreadPoints ? "AT OR ABOVE THRESHOLD" : "BELOW THRESHOLD"); string stressText = "Historical-scenario estimate: unavailable"; if(g_stressResultValid) stressText = "Historical-scenario estimate: " + DoubleToString(g_stressResultPoints, 1) + " points | " + (g_stressResultPoints >= InpMinimumAfterSpreadPoints ? "AT OR ABOVE THRESHOLD" : "BELOW THRESHOLD") + " (exit spread " + DoubleToString(g_stressSpreadPoints, 1) + " points)"; //--- Display the observed interval independently of percentile validity. string coverageText = "First sample: unavailable\n" "Last sample: unavailable\n" "Sample span: unavailable"; if(g_historySampleCount > 0) coverageText = "First sample: " + TimeToString(g_historyFirstSampleTime, TIME_DATE | TIME_SECONDS) + " (server)\nLast sample: " + TimeToString(g_historyLastSampleTime, TIME_DATE | TIME_SECONDS) + " (server)\nSample span: " + FormatHistorySpan(g_historySpanSeconds); string capText = (g_historyTickLimitHit ? "Tick cap restricted retrieval" : "Tick cap restriction not detected"); Comment("Trading Cost Planner\n", "Pre-trade spread-only estimates in points\n", "Advisory threshold: ", DoubleToString(InpMinimumAfterSpreadPoints, 1), " points\n", "Quote: ", g_quoteStatus, "\n", quoteText, "\n", historyText, "\n", "Valid samples: ", g_historySampleCount, "\n", "History: ", g_historyStatus, "\n", "Requested lookback: ", InpHistoryHours, " hours\n", coverageText, "\n", capText, "\n", currentText, "\n", stressText); }
Compile the EA and check Buy and Sell estimates against hand calculations using the displayed quote and assumed exit spreads. Increasing the assumed exit spread must not increase the estimate. When the historical percentile is no higher than the current spread, both estimates should agree.
Set InpMinimumHistorySamples above the available count. With a valid quote, the current-spread estimate should remain available while the historical-scenario estimate reads unavailable. Changing only the advisory threshold should change the comparison labels without changing either numeric estimate.
Replacing the Readout With a Collapsible Panel
The panel presents direction and volume, expected move and minimum, quote status, spread measurements, and both projected estimates. It also shows the historical sample count, first and last sample times, and sample span beside the requested lookback. Unavailable values remain distinct from valid estimates below the minimum.
The panel consists of EA-owned chart objects. Its background, title, and collapse button remain visible; seventeen information rows form the removable body. Collapsing deletes those rows, and expanding recreates and refreshes them. Deinitialization removes the EA’s panel objects.
In TradingCostPlanner.mq5, add the following declarations immediately after the planned-move calculation state:
//--- Dashboard state #define PANEL_ROW_COUNT 17 string g_panelPrefix = ""; // Names owned by this EA on this chart bool g_panelCollapsed = false; // Whether only the header is visible
The object helpers create the panel and remove only objects with this EA’s prefix. They also check chart-object operations that can fail. Add all seven new functions in the following listing immediately after CalculatePlannedMove() and before the existing UpdateQuoteReadout(). Keep them in the order shown. At this checkpoint, CreatePanelBody() creates only information rows; there is no execution button yet.
//+------------------------------------------------------------------+ //| Delete one dashboard object when it exists | //+------------------------------------------------------------------+ bool DeletePanelObject(const string name) { //--- An already absent object needs no cleanup. if(ObjectFind(0, name) < 0) return(true); //--- Identify any deletion failure by its chart-object name. ResetLastError(); if(!ObjectDelete(0, name)) { PrintFormat("Trading Cost Planner: Could not delete %s. Error %d.", name, GetLastError()); return(false); } return(true); } //+------------------------------------------------------------------+ //| Remove the expanded dashboard body | //+------------------------------------------------------------------+ bool DeletePanelBody() { bool success = true; //--- Attempt every row deletion even if an earlier deletion fails. //--- The header objects remain available for expansion. for(int row = 0; row < PANEL_ROW_COUNT; row++) { string name = g_panelPrefix + "ROW_" + IntegerToString(row); if(!DeletePanelObject(name)) success = false; } return(success); } //+------------------------------------------------------------------+ //| Remove all dashboard objects owned by this EA | //+------------------------------------------------------------------+ void DeletePanel() { //--- Delete the removable body before its persistent header. //--- Use only this EA's prefixed names. DeletePanelBody(); DeletePanelObject(g_panelPrefix + "TOGGLE"); DeletePanelObject(g_panelPrefix + "TITLE"); DeletePanelObject(g_panelPrefix + "BACKGROUND"); ChartRedraw(); } //+------------------------------------------------------------------+ //| Create a dashboard text label | //+------------------------------------------------------------------+ bool CreatePanelLabel(const string name, const int x, const int y, const int fontSize, const color textColor, const string text) { ResetLastError(); if(!ObjectCreate(0, name, OBJ_LABEL, 0, 0, 0)) { PrintFormat("Trading Cost Planner: Could not create %s. Error %d.", name, GetLastError()); return(false); } //--- Anchor labels to the upper-left corner and prevent selection //--- so chart interaction does not move individual panel rows. if(!ObjectSetInteger(0, name, OBJPROP_CORNER, CORNER_LEFT_UPPER) || !ObjectSetInteger(0, name, OBJPROP_XDISTANCE, x) || !ObjectSetInteger(0, name, OBJPROP_YDISTANCE, y) || !ObjectSetInteger(0, name, OBJPROP_FONTSIZE, fontSize) || !ObjectSetInteger(0, name, OBJPROP_COLOR, textColor) || !ObjectSetInteger(0, name, OBJPROP_SELECTABLE, false) || !ObjectSetInteger(0, name, OBJPROP_HIDDEN, true) || !ObjectSetString(0, name, OBJPROP_FONT, "Arial") || !ObjectSetString(0, name, OBJPROP_TEXT, text)) { PrintFormat("Trading Cost Planner: Could not configure %s. Error %d.", name, GetLastError()); return(false); } return(true); } //+------------------------------------------------------------------+ //| Create the expanded information-only panel body | //+------------------------------------------------------------------+ bool CreatePanelBody() { //--- Stable row names are reused by UpdatePanel(). Execution controls //--- are introduced only in the following article checkpoint. for(int row = 0; row < PANEL_ROW_COUNT; row++) { string name = g_panelPrefix + "ROW_" + IntegerToString(row); if(!CreatePanelLabel(name, InpPanelX + 14, InpPanelY + 48 + row * 23, 9, clrBlack, "")) return(false); } ResetLastError(); if(!ObjectSetInteger(0, g_panelPrefix + "BACKGROUND", OBJPROP_YSIZE, 48 + PANEL_ROW_COUNT * 23 + 12)) { PrintFormat("Trading Cost Planner: Could not resize panel. Error %d.", GetLastError()); return(false); } return(true); } //+------------------------------------------------------------------+ //| Create the white dashboard and its persistent header | //+------------------------------------------------------------------+ bool BuildPanel() { string background = g_panelPrefix + "BACKGROUND"; ResetLastError(); if(!ObjectCreate(0, background, OBJ_RECTANGLE_LABEL, 0, 0, 0)) { PrintFormat("Trading Cost Planner: Could not create panel background. Error %d.", GetLastError()); return(false); } if(!ObjectSetInteger(0, background, OBJPROP_CORNER, CORNER_LEFT_UPPER) || !ObjectSetInteger(0, background, OBJPROP_XDISTANCE, InpPanelX) || !ObjectSetInteger(0, background, OBJPROP_YDISTANCE, InpPanelY) || !ObjectSetInteger(0, background, OBJPROP_XSIZE, 650) || !ObjectSetInteger(0, background, OBJPROP_YSIZE, 290) || !ObjectSetInteger(0, background, OBJPROP_BGCOLOR, clrWhite) || !ObjectSetInteger(0, background, OBJPROP_BORDER_COLOR, clrSilver) || !ObjectSetInteger(0, background, OBJPROP_BACK, false) || !ObjectSetInteger(0, background, OBJPROP_SELECTABLE, false) || !ObjectSetInteger(0, background, OBJPROP_HIDDEN, true)) { PrintFormat("Trading Cost Planner: Could not configure panel background. Error %d.", GetLastError()); DeletePanel(); return(false); } if(!CreatePanelLabel(g_panelPrefix + "TITLE", InpPanelX + 14, InpPanelY + 11, 12, clrBlack, "TRADING COST PLANNER")) { DeletePanel(); return(false); } string toggle = g_panelPrefix + "TOGGLE"; ResetLastError(); if(!ObjectCreate(0, toggle, OBJ_BUTTON, 0, 0, 0)) { PrintFormat("Trading Cost Planner: Could not create collapse button. Error %d.", GetLastError()); DeletePanel(); return(false); } if(!ObjectSetInteger(0, toggle, OBJPROP_CORNER, CORNER_LEFT_UPPER) || !ObjectSetInteger(0, toggle, OBJPROP_XDISTANCE, InpPanelX + 611) || !ObjectSetInteger(0, toggle, OBJPROP_YDISTANCE, InpPanelY + 7) || !ObjectSetInteger(0, toggle, OBJPROP_XSIZE, 27) || !ObjectSetInteger(0, toggle, OBJPROP_YSIZE, 24) || !ObjectSetInteger(0, toggle, OBJPROP_BGCOLOR, clrWhite) || !ObjectSetInteger(0, toggle, OBJPROP_COLOR, clrBlack) || !ObjectSetInteger(0, toggle, OBJPROP_BORDER_COLOR, clrSilver) || !ObjectSetInteger(0, toggle, OBJPROP_SELECTABLE, false) || !ObjectSetInteger(0, toggle, OBJPROP_HIDDEN, true) || !ObjectSetString(0, toggle, OBJPROP_TEXT, "-")) { PrintFormat("Trading Cost Planner: Could not configure collapse button. Error %d.", GetLastError()); DeletePanel(); return(false); } if(!CreatePanelBody()) { DeletePanel(); return(false); } ChartRedraw(); return(true); } //+------------------------------------------------------------------+ //| Update one dashboard row | //+------------------------------------------------------------------+ bool SetPanelRow(const int row, const string text, const color textColor) { //--- Resolve the same row name used during body creation. string name = g_panelPrefix + "ROW_" + IntegerToString(row); ResetLastError(); if(!ObjectSetString(0, name, OBJPROP_TEXT, text) || !ObjectSetInteger(0, name, OBJPROP_COLOR, textColor)) { PrintFormat("Trading Cost Planner: Could not update row %d. Error %d.", row, GetLastError()); return(false); } return(true); }
UpdatePanel() turns the quote, history, and calculation state into visible text. A missing quote or historical result stays labeled unavailable, while valid projections receive the minimum comparison. Add this new function immediately after SetPanelRow():
//+------------------------------------------------------------------+ //| Display pre-trade estimates, readiness, and the latest outcome | //+------------------------------------------------------------------+ void UpdatePanel() { //--- Collapsing removes the body objects. Continue updating runtime //--- state elsewhere, but do not write to rows that are not present. if(g_panelCollapsed) return; string direction = (InpDirection == PLAN_BUY ? "BUY" : "SELL"); //--- Start with unavailable text. Publish numeric quote values only //--- when the latest snapshot passed all quote validation checks. string quoteText = "Bid/Ask: unavailable"; string spreadText = "Current spread: unavailable"; if(g_quoteValid) { quoteText = "Bid " + DoubleToString(g_bid, _Digits) + " | Ask " + DoubleToString(g_ask, _Digits); spreadText = "Current spread: " + DoubleToString(g_spreadPoints, 1) + " points"; } //--- Preserve the sample count even when there are too few samples //--- to support the configured historical percentile. string historyText = "Historical spread: unavailable | " + IntegerToString(g_historySampleCount) + " samples"; if(g_historyAvailable) historyText = "Historical P" + IntegerToString(InpStressPercentile) + ": " + DoubleToString(g_historicalSpreadPoints, 1) + " points | " + IntegerToString(g_historySampleCount) + " samples"; //--- Numeric estimates and advisory comparisons use separate rows. //--- An unavailable estimate must not receive a threshold verdict. string currentText = "Current-spread estimate: unavailable"; string stressText = "Historical-scenario estimate: unavailable"; string currentStatus = "Current-spread comparison: unavailable"; string stressStatus = "Historical-scenario comparison: unavailable"; color currentColor = clrDimGray; color stressColor = clrDimGray; if(g_currentResultValid) { bool aboveThreshold = (g_currentResultPoints >= InpMinimumAfterSpreadPoints); currentText = StringFormat("Current-spread estimate: %.1f points", g_currentResultPoints); currentStatus = "Current-spread comparison: " + (aboveThreshold ? "AT OR ABOVE THRESHOLD" : "BELOW THRESHOLD"); currentColor = (aboveThreshold ? clrForestGreen : clrFireBrick); } if(g_stressResultValid) { bool aboveThreshold = (g_stressResultPoints >= InpMinimumAfterSpreadPoints); stressText = StringFormat("Historical-scenario estimate: %.1f points", g_stressResultPoints); stressStatus = "Historical-scenario comparison: " + (aboveThreshold ? "AT OR ABOVE THRESHOLD" : "BELOW THRESHOLD"); stressColor = (aboveThreshold ? clrForestGreen : clrFireBrick); } //--- Sample boundaries describe accepted observations. They do not //--- imply uninterrupted tick coverage between those timestamps. string firstText = "First sample: unavailable"; string lastText = "Last sample: unavailable"; string spanText = StringFormat("Requested: %d h | Sample span: unavailable", InpHistoryHours); if(g_historySampleCount > 0) { firstText = "First sample: " + TimeToString(g_historyFirstSampleTime, TIME_DATE | TIME_SECONDS) + " (server)"; lastText = "Last sample: " + TimeToString(g_historyLastSampleTime, TIME_DATE | TIME_SECONDS) + " (server)"; spanText = StringFormat("Requested: %d h | Sample span: %s", InpHistoryHours, FormatHistorySpan(g_historySpanSeconds)); } string historyStatusText = "History: " + g_historyStatus; if(g_historyTickLimitHit && !g_historyAvailable) historyStatusText += " | TICK CAP"; //--- Threshold comparisons are advisory. This checkpoint has no trading controls. if(!SetPanelRow(0, StringFormat("Order size: %s %.8g lots", direction, InpVolume), clrBlack) || !SetPanelRow(1, StringFormat("Expected midpoint move: %.1f points", InpTargetMovePoints), clrBlack) || !SetPanelRow(2, quoteText, clrBlack) || !SetPanelRow(3, "Quote: " + g_quoteStatus, g_quoteValid ? clrForestGreen : clrFireBrick) || !SetPanelRow(4, spreadText, clrBlack) || !SetPanelRow(5, historyText, clrBlack) || !SetPanelRow(6, historyStatusText, g_historyAvailable ? clrForestGreen : clrFireBrick) || !SetPanelRow(7, currentText, currentColor) || !SetPanelRow(8, stressText, stressColor) || !SetPanelRow(9, "SL: none | TP: none", clrFireBrick) || !SetPanelRow(10, firstText, clrBlack) || !SetPanelRow(11, lastText, clrBlack) || !SetPanelRow(12, spanText, clrBlack) || !SetPanelRow(13, StringFormat("Advisory threshold: %.1f points", InpMinimumAfterSpreadPoints), clrBlack) || !SetPanelRow(14, currentStatus, currentColor) || !SetPanelRow(15, stressStatus, stressColor) || !SetPanelRow(16, "Pre-trade spread-only estimates in points", clrDimGray)) return; ChartRedraw(); }
SetPanelCollapsed() changes which objects exist, rather than merely covering the rows. Add this new function immediately after UpdatePanel():
//+------------------------------------------------------------------+ //| Collapse or expand the dashboard body | //+------------------------------------------------------------------+ bool SetPanelCollapsed(const bool collapsed) { if(collapsed == g_panelCollapsed) return(true); string background = g_panelPrefix + "BACKGROUND"; string toggle = g_panelPrefix + "TOGGLE"; if(collapsed) { //--- Delete information rows and trade controls while preserving //--- the header objects needed to expand the panel again. if(!DeletePanelBody()) return(false); if(!ObjectSetInteger(0, background, OBJPROP_YSIZE, 38) || !ObjectSetString(0, toggle, OBJPROP_TEXT, "+")) { Print("Trading Cost Planner: Could not collapse the panel."); return(false); } g_panelCollapsed = true; } else { if(!CreatePanelBody()) { DeletePanelBody(); return(false); } //--- Use the same layout calculation as CreateTradeControls(). int panelHeight = 48 + PANEL_ROW_COUNT * 23 + 12; if(!ObjectSetInteger(0, background, OBJPROP_YSIZE, panelHeight) || !ObjectSetString(0, toggle, OBJPROP_TEXT, "-")) { Print("Trading Cost Planner: Could not expand the panel."); DeletePanelBody(); return(false); } g_panelCollapsed = false; UpdatePanel(); } ChartRedraw(); return(true); }
Replace the entire existing UpdateQuoteReadout() function with this complete version. It keeps the quote and projection updates from Section 5 but sends their values to the panel instead of Comment().
//+------------------------------------------------------------------+ //| Refresh the quote, projections, and dashboard | //+------------------------------------------------------------------+ void UpdateQuoteReadout() { //--- Read Bid and Ask together and update the quote status. //--- A failed read invalidates the previous quote. ReadCurrentQuote(); //--- Recalculate both spread scenarios from the current quote state. //--- Results remain unavailable when their required data is invalid. CalculatePlannedMove(); //--- Display the refreshed values and their availability statuses. UpdatePanel(); }
The panel must be created before the first display update and removed when the EA is detached. Replace the existing OnInit(), OnDeinit(), and OnChartEvent() functions with the complete blocks below. Keep exactly one copy of each function. OnTick() and OnTimer() remain unchanged.
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- Reject an invalid plan or unusable symbol properties before //--- creating objects or starting periodic updates. if(!ValidateInputs() || !InitializeSymbolProperties()) return(INIT_PARAMETERS_INCORRECT); //--- Use this chart's ID to identify the objects owned by this EA. //--- Begin with the panel expanded so its body can be created. g_panelPrefix = StringFormat("TCP_%I64d_", ChartID()); g_panelCollapsed = false; if(!BuildPanel()) return(INIT_FAILED); //--- Start periodic updates only after the panel exists. Remove the //--- panel if timer setup fails, leaving no partial chart display. ResetLastError(); if(!EventSetTimer(5)) { PrintFormat("Trading Cost Planner: Could not start the timer. Error %d.", GetLastError()); DeletePanel(); return(INIT_FAILED); } g_timerActive = true; //--- Clear the earlier temporary chart comment and populate the panel //--- immediately; the first timer event is not needed for its first read. Comment(""); UpdateQuoteReadout(); return(INIT_SUCCEEDED); } //+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { //--- Stop scheduled updates before deleting the chart objects they //--- would update. The EA removes only objects under its own prefix. if(g_timerActive) { EventKillTimer(); g_timerActive = false; } DeletePanel(); } //+------------------------------------------------------------------+ //| Chart event function | //+------------------------------------------------------------------+ void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam) { //--- Ignore chart events and object clicks unrelated to this EA's //--- persistent collapse button. if(id != CHARTEVENT_OBJECT_CLICK || sparam != g_panelPrefix + "TOGGLE") return; //--- Release the button's pressed state before changing the panel. //--- If release fails, leave the current panel state untouched. ResetLastError(); if(!ObjectSetInteger(0, sparam, OBJPROP_STATE, false)) { PrintFormat("Trading Cost Planner: Could not release panel button. Error %d.", GetLastError()); return; } //--- Remove or recreate the body using the opposite of its current state. SetPanelCollapsed(!g_panelCollapsed); }
Compile and attach the EA to light and dark charts. Check that the white panel displays all seventeen rows, including sample boundaries and the requested lookback beside the actual span. Collapse should leave only the header; expansion should restore the rows with current values.
Adding Manual Market Execution
Calculations and panel refreshes send no order. Only an Execute click obtains a fresh quote and checks volume, trading permissions, account-specific position restrictions, and estimated margin before submitting a synchronous market Buy or Sell. The advisory threshold remains independent of execution readiness.
The expected move supplies a pre-trade spread-only estimate. Execute attaches no SL, TP, or automatic exit. Actual execution details are reported separately; the point estimates are not recalculated from the fill price. Add the trade-library include immediately after #property strict:
#include <Trade\Trade.mqh>Change the version property to 1.01. This identifies the corrected source candidate; it does not establish a compiler or demo-test result. Replace the complete Quote and execution input group with:
//--- Quote and execution settings input group "Quote and execution" input int InpQuoteMaxAgeSeconds = 10; // Maximum usable quote age in seconds input int InpMaxDeviationPoints = 20; // Requested order deviation in points input long InpMagicNumber = 202610; // Positive identifier for this EA's trades
Replace the dashboard state block with:
//--- Dashboard state #define PANEL_ROW_COUNT 18 string g_panelPrefix = ""; // Names owned by this EA on this chart bool g_panelCollapsed = false; // Whether only the header is visible
Immediately after the dashboard state block, add both state blocks below. The attempt retains the terminal request ID, exact order and deal tickets, requested volume and direction, and the position identifier established by execution deals.
//--- Manual execution state CTrade g_trade; bool g_requestPending = false; // A button request is being processed bool g_tradeLocked = false; // A fill or uncertain outcome prevents another request bool g_fillConfirmed = false; // A confirmed fill awaits position closure datetime g_fillTime = 0; // Time of the confirmed fill string g_tradeStatus = "No order sent"; // Latest execution outcome //--- Execution-attempt reconciliation state uint g_attemptRequestId = 0; // Terminal-assigned request identifier ulong g_attemptOrder = 0; // Exact originating order ticket ulong g_attemptDeal = 0; // Returned or reconciled execution deal ulong g_attemptPositionId = 0; // Position identifier established by deals datetime g_attemptTime = 0; // Server time before submission double g_attemptVolume = 0.0; // Requested volume retained for comparison ENUM_ORDER_TYPE g_attemptType = ORDER_TYPE_BUY; // Requested direction string g_attemptComment = ""; // Attempt token for matching request events string g_reconciliationStatus = "No pending reconciliation"; // Current lock explanation
Add ValidateMagicNumber() immediately after ValidateInputs(). Use a positive magic number distinct from other instances that should have separate trade identities.
//+------------------------------------------------------------------+ //| Validate the identifier assigned to this EA's trade requests | //+------------------------------------------------------------------+ bool ValidateMagicNumber() { //--- Reserve zero for trades without this EA's positive identifier. //--- A signed input also permits a direct check against negative values. if(InpMagicNumber <= 0) { Print("Trading Cost Planner: Magic number must be greater than zero."); return(false); } return(true); }
The expanded panel owns its execution button and outcome label. Add CreateTradeControls() immediately after CreatePanelLabel(). Its layout follows PANEL_ROW_COUNT.
//+------------------------------------------------------------------+ //| Create execution controls below the information rows | //+------------------------------------------------------------------+ bool CreateTradeControls() { string button = g_panelPrefix + "EXECUTE"; //--- Derive positions from the row count so added information rows //--- cannot overlap the execution button and its status label. int buttonY = InpPanelY + 48 + PANEL_ROW_COUNT * 23 + 7; int statusY = buttonY + 38; int panelHeight = statusY - InpPanelY + 27; ResetLastError(); if(!ObjectCreate(0, button, OBJ_BUTTON, 0, 0, 0)) { PrintFormat("Trading Cost Planner: Could not create execution button. Error %d.", GetLastError()); return(false); } if(!ObjectSetInteger(0, button, OBJPROP_CORNER, CORNER_LEFT_UPPER) || !ObjectSetInteger(0, button, OBJPROP_XDISTANCE, InpPanelX + 14) || !ObjectSetInteger(0, button, OBJPROP_YDISTANCE, buttonY) || !ObjectSetInteger(0, button, OBJPROP_XSIZE, 205) || !ObjectSetInteger(0, button, OBJPROP_YSIZE, 30) || !ObjectSetInteger(0, button, OBJPROP_BGCOLOR, clrForestGreen) || !ObjectSetInteger(0, button, OBJPROP_COLOR, clrWhite) || !ObjectSetInteger(0, button, OBJPROP_SELECTABLE, false) || !ObjectSetInteger(0, button, OBJPROP_HIDDEN, true) || !ObjectSetString(0, button, OBJPROP_TEXT, "EXECUTE AT MARKET")) { PrintFormat("Trading Cost Planner: Could not configure execution button. Error %d.", GetLastError()); return(false); } if(!CreatePanelLabel(g_panelPrefix + "TRADE_STATUS", InpPanelX + 14, statusY, 9, clrBlack, g_tradeStatus)) return(false); //--- Include all rows and controls inside the white background. ResetLastError(); if(!ObjectSetInteger(0, g_panelPrefix + "BACKGROUND", OBJPROP_YSIZE, panelHeight)) { PrintFormat("Trading Cost Planner: Could not extend panel background. Error %d.", GetLastError()); return(false); } return(true); }
Replace DeletePanelBody(), CreatePanelBody(), and SetPanelCollapsed() in their existing locations. Collapsing removes the information rows and controls; expanding reconstructs them without clearing execution state.
//+------------------------------------------------------------------+ //| Remove the expanded dashboard body and its controls | //+------------------------------------------------------------------+ bool DeletePanelBody() { bool success = true; for(int row = 0; row < PANEL_ROW_COUNT; row++) { string name = g_panelPrefix + "ROW_" + IntegerToString(row); if(!DeletePanelObject(name)) success = false; } if(!DeletePanelObject(g_panelPrefix + "EXECUTE")) success = false; if(!DeletePanelObject(g_panelPrefix + "TRADE_STATUS")) success = false; return(success); } //+------------------------------------------------------------------+ //| Create information rows and manual execution controls | //+------------------------------------------------------------------+ bool CreatePanelBody() { //--- Each row has a stable object name. Updating its text does not //--- require recreating the panel on every quote or timer event. for(int row = 0; row < PANEL_ROW_COUNT; row++) { string name = g_panelPrefix + "ROW_" + IntegerToString(row); if(!CreatePanelLabel(name, InpPanelX + 14, InpPanelY + 48 + row * 23, 9, clrBlack, "")) return(false); } //--- Execution controls calculate their position from the same //--- row count and extend the background to include the outcome label. return(CreateTradeControls()); } //+------------------------------------------------------------------+ //| Collapse or expand the dashboard body | //+------------------------------------------------------------------+ bool SetPanelCollapsed(const bool collapsed) { if(collapsed == g_panelCollapsed) return(true); string background = g_panelPrefix + "BACKGROUND"; string toggle = g_panelPrefix + "TOGGLE"; if(collapsed) { //--- Delete information rows and trade controls while preserving //--- the header objects needed to expand the panel again. if(!DeletePanelBody()) return(false); if(!ObjectSetInteger(0, background, OBJPROP_YSIZE, 38) || !ObjectSetString(0, toggle, OBJPROP_TEXT, "+")) { Print("Trading Cost Planner: Could not collapse the panel."); return(false); } g_panelCollapsed = true; } else { if(!CreatePanelBody()) { DeletePanelBody(); return(false); } //--- Use the same layout calculation as CreateTradeControls(). int panelHeight = 48 + PANEL_ROW_COUNT * 23 + 7 + 38 + 27; if(!ObjectSetInteger(0, background, OBJPROP_YSIZE, panelHeight) || !ObjectSetString(0, toggle, OBJPROP_TEXT, "-")) { Print("Trading Cost Planner: Could not expand the panel."); DeletePanelBody(); return(false); } g_panelCollapsed = false; UpdatePanel(); } ChartRedraw(); return(true); }
On hedging accounts, only positions matching the symbol and magic number block a new request. On netting and exchange accounts, any position on the symbol blocks it because exposure is combined. Add CheckPositionRule() immediately after SetPanelRow().
//+------------------------------------------------------------------+ //| Apply the account-specific existing-position rule | //+------------------------------------------------------------------+ bool CheckPositionRule(string &reason) { reason = ""; long marginMode = AccountInfoInteger(ACCOUNT_MARGIN_MODE); bool hedging = (marginMode == ACCOUNT_MARGIN_MODE_RETAIL_HEDGING); //--- Recognize both netting account modes explicitly. An unknown //--- mode must not silently inherit a position policy. if(!hedging && marginMode != ACCOUNT_MARGIN_MODE_RETAIL_NETTING && marginMode != ACCOUNT_MARGIN_MODE_EXCHANGE) { reason = "Unsupported account position mode"; return(false); } int positionCount = PositionsTotal(); //--- Examine every position because hedging accounts can contain //--- several positions on the same symbol with different identifiers. for(int index = 0; index < positionCount; index++) { ResetLastError(); ulong ticket = PositionGetTicket(index); if(ticket == 0) { reason = StringFormat("Cannot read position ticket: error %d", GetLastError()); return(false); } //--- Reselect by ticket before reading properties. If account state //--- changes during the scan, stop rather than assume no position exists. ResetLastError(); if(!PositionSelectByTicket(ticket)) { reason = StringFormat("Cannot select position: error %d", GetLastError()); return(false); } string symbol = ""; ResetLastError(); if(!PositionGetString(POSITION_SYMBOL, symbol)) { reason = StringFormat("Cannot read position symbol: error %d", GetLastError()); return(false); } if(symbol != _Symbol) continue; //--- Netting combines exposure on the symbol. Magic number cannot //--- isolate a new order from an existing combined position. if(!hedging) { reason = "A position exists on this netting symbol"; return(false); } long magic = 0; ResetLastError(); if(!PositionGetInteger(POSITION_MAGIC, magic)) { reason = StringFormat("Cannot read position magic: error %d", GetLastError()); return(false); } //--- On hedging accounts, unrelated positions are permitted. //--- Only this symbol-and-magic identification group blocks execution. if(magic == InpMagicNumber) { reason = "A matching EA position exists on this symbol"; return(false); } } return(true); }
OrderCalcMargin() estimates standalone margin using the requested volume and Ask for Buy or Bid for Sell. It excludes existing positions and pending orders from that calculation, so comparison with free margin can be conservative on hedging accounts. Passing this check does not guarantee broker acceptance. Add CheckMarginReady() immediately after CheckPositionRule().
//+------------------------------------------------------------------+ //| Compare estimated order margin with current free margin | //+------------------------------------------------------------------+ bool CheckMarginReady(string &reason) { reason = ""; //--- The caller must provide a validated quote. The execution path //--- refreshes that quote immediately before running readiness checks. if(!g_quoteValid) { reason = g_quoteStatus; return(false); } ENUM_ORDER_TYPE orderType; double openingPrice = 0.0; if(InpDirection == PLAN_BUY) { orderType = ORDER_TYPE_BUY; openingPrice = g_ask; } else if(InpDirection == PLAN_SELL) { orderType = ORDER_TYPE_SELL; openingPrice = g_bid; } else { reason = "Invalid trade direction"; return(false); } //--- Calculate standalone margin for the requested volume. //--- Failure to obtain an estimate blocks the request. double requiredMargin = 0.0; ResetLastError(); if(!OrderCalcMargin(orderType, _Symbol, InpVolume, openingPrice, requiredMargin)) { reason = StringFormat("Cannot estimate margin: error %d", GetLastError()); return(false); } if(!MathIsValidNumber(requiredMargin) || requiredMargin < 0.0) { reason = "Invalid estimated margin"; return(false); } double freeMargin = AccountInfoDouble(ACCOUNT_MARGIN_FREE); if(!MathIsValidNumber(freeMargin)) { reason = "Invalid account free margin"; return(false); } //--- This is a preliminary affordability comparison, not a broker //--- approval. Prices, account state, and margin treatment can change. if(freeMargin < requiredMargin) { reason = StringFormat("Insufficient margin: need %.2f, free %.2f %s", requiredMargin, freeMargin, AccountInfoString(ACCOUNT_CURRENCY)); return(false); } return(true); }
Add CheckMarketTradeReady() immediately after CheckMarginReady(). Both display refreshes and execution use these checks. Existing volume validation retains the documented multiple-of-step rule; unsupported volume is rejected without rounding.
//+------------------------------------------------------------------+ //| Check current prerequisites for a market-order request | //+------------------------------------------------------------------+ bool CheckMarketTradeReady(string &reason) { reason = ""; //--- Keep duplicate-request and unresolved-outcome protection ahead //--- of all other checks. if(g_requestPending) { reason = "Order request in progress"; return(false); } if(g_tradeLocked) { reason = "Locked: " + g_reconciliationStatus; return(false); } if(!g_quoteValid) { reason = g_quoteStatus; return(false); } if(!TerminalInfoInteger(TERMINAL_CONNECTED)) { reason = "Terminal is disconnected"; return(false); } //--- All relevant terminal, program, and account permissions //--- must permit an Expert Advisor trade request. if(!TerminalInfoInteger(TERMINAL_TRADE_ALLOWED) || !MQLInfoInteger(MQL_TRADE_ALLOWED) || !AccountInfoInteger(ACCOUNT_TRADE_ALLOWED) || !AccountInfoInteger(ACCOUNT_TRADE_EXPERT)) { reason = "EA trading is not permitted"; return(false); } if(InpMagicNumber <= 0) { reason = "Invalid EA magic number"; return(false); } //--- Reuse the existing range and volume-step validation. //--- Reject unsupported volume without silently rounding the order. if(!InitializeSymbolProperties()) { reason = "Invalid volume or symbol properties; see Experts"; return(false); } //--- Use the same account-specific position rule for display, //--- click-time permission, and confirmed-fill lock reconciliation. if(!CheckPositionRule(reason)) return(false); long tradeMode = 0; long orderMode = 0; ResetLastError(); if(!SymbolInfoInteger(_Symbol, SYMBOL_TRADE_MODE, tradeMode) || !SymbolInfoInteger(_Symbol, SYMBOL_ORDER_MODE, orderMode)) { reason = StringFormat("Cannot read symbol trading rules: error %d", GetLastError()); return(false); } if((orderMode & SYMBOL_ORDER_MARKET) == 0) { reason = "Market orders are not allowed on this symbol"; return(false); } if(tradeMode == SYMBOL_TRADE_MODE_DISABLED || tradeMode == SYMBOL_TRADE_MODE_CLOSEONLY || (InpDirection == PLAN_BUY && tradeMode == SYMBOL_TRADE_MODE_SHORTONLY) || (InpDirection == PLAN_SELL && tradeMode == SYMBOL_TRADE_MODE_LONGONLY)) { reason = "Selected direction is not allowed on this symbol"; return(false); } //--- Estimate affordability only after volume and symbol rules //--- have passed. The advisory point threshold is not checked here. if(!CheckMarginReady(reason)) return(false); return(true); }
An uncertain outcome remains locked until associated broker records establish the result. A definite request failure can release the lock. Otherwise, reconciliation requires the exact order's terminal state and consistent execution-deal volume. An active remainder after a partial fill keeps the request locked. Missing history or an absent position alone does not establish failure, and no reconciliation path retries the order.
Add the following three complete functions immediately after CheckMarketTradeReady(), in the order shown. They classify definite failures, reconcile the retained attempt, and submit the manual request. Reconciled execution volume and its volume-weighted price are reported separately from the pre-trade estimates.
//+------------------------------------------------------------------+ //| Identify responses that establish a failed market request | //+------------------------------------------------------------------+ bool IsDefiniteRequestFailure(const uint retcode) { //--- Use an explicit list. An unknown or missing response must not //--- unlock a request merely because it is not a successful retcode. switch(retcode) { case TRADE_RETCODE_REQUOTE: case TRADE_RETCODE_REJECT: case TRADE_RETCODE_CANCEL: case TRADE_RETCODE_INVALID: case TRADE_RETCODE_INVALID_VOLUME: case TRADE_RETCODE_INVALID_PRICE: case TRADE_RETCODE_INVALID_STOPS: case TRADE_RETCODE_TRADE_DISABLED: case TRADE_RETCODE_MARKET_CLOSED: case TRADE_RETCODE_NO_MONEY: case TRADE_RETCODE_PRICE_CHANGED: case TRADE_RETCODE_PRICE_OFF: case TRADE_RETCODE_INVALID_EXPIRATION: case TRADE_RETCODE_TOO_MANY_REQUESTS: case TRADE_RETCODE_SERVER_DISABLES_AT: case TRADE_RETCODE_CLIENT_DISABLES_AT: case TRADE_RETCODE_INVALID_FILL: case TRADE_RETCODE_LIMIT_ORDERS: case TRADE_RETCODE_LIMIT_VOLUME: case TRADE_RETCODE_INVALID_ORDER: return(true); } return(false); } //+------------------------------------------------------------------+ //| Reconcile the retained attempt without sending another request | //+------------------------------------------------------------------+ void RefreshTradeLock() { if(!g_tradeLocked || g_requestPending) return; //--- Disconnection or unavailable server time cannot prove closure //--- or failure. Preserve the lock until broker records can be read. datetime now = TimeTradeServer(); if(!TerminalInfoInteger(TERMINAL_CONNECTED) || now <= 0) { g_reconciliationStatus = "Waiting for connection and server time"; return; } //--- A returned deal can supply its exact originating order ticket. //--- Never match an attempt merely by the absence of a position. if(g_attemptOrder == 0 && g_attemptDeal > 0) { long order = 0; if(HistoryDealSelect(g_attemptDeal) && HistoryDealGetInteger(g_attemptDeal, DEAL_ORDER, order) && order > 0) g_attemptOrder = (ulong)order; } if(g_attemptOrder == 0) { g_reconciliationStatus = "No matching order ticket yet; request stays locked"; return; } //--- A still-active order can execute further, including a remainder //--- after partial execution. Do not permit another request. if(OrderSelect(g_attemptOrder)) { g_reconciliationStatus = "Matching order is active; request stays locked"; return; } //--- Select broker history from just before this attempt. Failure //--- or missing history is an unavailable state, not proof of rejection. ResetLastError(); if(!HistorySelect(g_attemptTime - 1, now)) { g_reconciliationStatus = StringFormat("History unavailable: error %d", GetLastError()); return; } long orderMagic = 0; long orderType = 0; long orderState = 0; string orderSymbol = ""; double initialVolume = 0.0; double remainingVolume = 0.0; //--- Validate identity and final volume using checked property reads. //--- The order ticket is the primary association; magic and symbol //--- prevent adopting an unrelated record. if(!HistoryOrderGetInteger(g_attemptOrder, ORDER_MAGIC, orderMagic) || !HistoryOrderGetInteger(g_attemptOrder, ORDER_TYPE, orderType) || !HistoryOrderGetInteger(g_attemptOrder, ORDER_STATE, orderState) || !HistoryOrderGetString(g_attemptOrder, ORDER_SYMBOL, orderSymbol) || !HistoryOrderGetDouble(g_attemptOrder, ORDER_VOLUME_INITIAL, initialVolume) || !HistoryOrderGetDouble(g_attemptOrder, ORDER_VOLUME_CURRENT, remainingVolume)) { g_reconciliationStatus = "Matching order history is incomplete; lock retained"; return; } if(orderMagic != InpMagicNumber || orderSymbol != _Symbol || orderType != (long)g_attemptType || !MathIsValidNumber(initialVolume) || initialVolume <= 0.0 || !MathIsValidNumber(remainingVolume) || remainingVolume < 0.0 || remainingVolume > initialVolume) { g_reconciliationStatus = "Order identity or volume is inconsistent; lock retained"; return; } bool terminalState = (orderState == ORDER_STATE_FILLED || orderState == ORDER_STATE_CANCELED || orderState == ORDER_STATE_REJECTED || orderState == ORDER_STATE_EXPIRED); if(!terminalState) { g_reconciliationStatus = "Order outcome is not final; lock retained"; return; } //--- Aggregate the exact order's execution deals. Multiple deals //--- produce total filled volume and a volume-weighted entry price. double volume = 0.0; double priceVolume = 0.0; ulong lastDeal = 0; ulong positionId = 0; int dealCount = HistoryDealsTotal(); for(int index = 0; index < dealCount; index++) { ulong ticket = HistoryDealGetTicket(index); long dealOrder = 0; if(ticket == 0 || !HistoryDealGetInteger(ticket, DEAL_ORDER, dealOrder)) { g_reconciliationStatus = "Deal history read failed; lock retained"; return; } if((ulong)dealOrder != g_attemptOrder) continue; long dealType = 0; if(!HistoryDealGetInteger(ticket, DEAL_TYPE, dealType)) return; //--- Ignore non-price deal records such as separate charges. if(dealType != DEAL_TYPE_BUY && dealType != DEAL_TYPE_SELL) continue; long magic = 0; long id = 0; string symbol = ""; double lots = 0.0; double price = 0.0; if(!HistoryDealGetInteger(ticket, DEAL_MAGIC, magic) || !HistoryDealGetInteger(ticket, DEAL_POSITION_ID, id) || !HistoryDealGetString(ticket, DEAL_SYMBOL, symbol) || !HistoryDealGetDouble(ticket, DEAL_VOLUME, lots) || !HistoryDealGetDouble(ticket, DEAL_PRICE, price) || magic != InpMagicNumber || symbol != _Symbol || id <= 0 || dealType != (g_attemptType == ORDER_TYPE_BUY ? DEAL_TYPE_BUY : DEAL_TYPE_SELL) || !MathIsValidNumber(lots) || lots <= 0.0 || !MathIsValidNumber(price) || price <= 0.0) { g_reconciliationStatus = "Matching deal data is incomplete; lock retained"; return; } if(positionId != 0 && positionId != (ulong)id) { g_reconciliationStatus = "Multiple position identifiers; lock retained"; return; } positionId = (ulong)id; volume += lots; priceVolume += lots * price; lastDeal = ticket; } //--- Require consistent final order and deal volumes. This prevents //--- early unlocking while a partial set of execution records arrives. double tolerance = MathMax(1.0e-8, g_volumeStep * 1.0e-6); double executedVolume = initialVolume - remainingVolume; if(orderState == ORDER_STATE_FILLED) executedVolume = initialVolume; if(MathAbs(initialVolume - g_attemptVolume) > tolerance || MathAbs(volume - executedVolume) > tolerance) { g_reconciliationStatus = "Waiting for consistent final order and deal volumes"; return; } if(volume <= 0.0) { //--- Only a final non-filled order with unchanged volume and no //--- execution deals establishes that this order did not execute. if(orderState == ORDER_STATE_FILLED || g_fillConfirmed) return; g_tradeStatus = StringFormat("NOT FILLED: order %I64u; state %s", g_attemptOrder, EnumToString((ENUM_ORDER_STATE)orderState)); g_tradeLocked = false; g_reconciliationStatus = "Final no-fill outcome confirmed"; return; } //--- Broker history confirms execution. Preserve its outcome even //--- after closure and retain the originating position identifier. g_fillConfirmed = true; g_attemptPositionId = positionId; g_attemptDeal = lastDeal; if(g_fillTime == 0) g_fillTime = now; g_tradeStatus = StringFormat("%s: %.8g lots at %.*f; order %I64u", volume < initialVolume - tolerance ? "PARTIAL FILL" : "FILLED", volume, _Digits, priceVolume / volume, g_attemptOrder); if(now <= g_fillTime + 5) { g_reconciliationStatus = "Confirmed execution; waiting for account settlement"; return; } //--- Keep the lock while the exact position identifier exists, even //--- if another operation has changed its magic or netting attributes. int positionCount = PositionsTotal(); for(int index = 0; index < positionCount; index++) { ulong ticket = PositionGetTicket(index); long id = 0; if(ticket == 0 || !PositionSelectByTicket(ticket) || !PositionGetInteger(POSITION_IDENTIFIER, id)) { g_reconciliationStatus = "Position scan failed; lock retained"; return; } if((ulong)id == g_attemptPositionId) { g_reconciliationStatus = "Confirmed position remains open"; return; } } string reason = ""; if(!CheckPositionRule(reason)) { g_reconciliationStatus = reason; return; } g_tradeLocked = false; g_reconciliationStatus = "Execution reconciled; position no longer present"; } //+------------------------------------------------------------------+ //| Submit one manual request and retain its reconciliation identity | //+------------------------------------------------------------------+ void ExecuteMarketOrder() { ReadCurrentQuote(); CalculatePlannedMove(); string reason = ""; if(!CheckMarketTradeReady(reason)) { PrintFormat("Trading Cost Planner: Order blocked: %s.", reason); UpdatePanel(); return; } g_requestPending = true; UpdatePanel(); ResetLastError(); if(!g_trade.SetTypeFillingBySymbol(_Symbol)) { PrintFormat("Trading Cost Planner: Unsupported filling policy. Error %d.", GetLastError()); g_requestPending = false; UpdatePanel(); return; } //--- Retain the attempt before submission. Lock first so any unclear //--- response cannot enable another click. Comment matching is used //--- only for request events when a request ID was not returned. g_attemptTime = TimeTradeServer(); g_attemptVolume = InpVolume; g_attemptType = (InpDirection == PLAN_BUY ? ORDER_TYPE_BUY : ORDER_TYPE_SELL); g_attemptComment = StringFormat("TCP_%I64u", GetMicrosecondCount()); g_attemptRequestId = 0; g_attemptOrder = 0; g_attemptDeal = 0; g_attemptPositionId = 0; g_fillConfirmed = false; g_fillTime = 0; g_tradeLocked = true; g_reconciliationStatus = "Awaiting broker outcome"; g_tradeStatus = "Sending market order..."; g_trade.SetExpertMagicNumber((ulong)InpMagicNumber); g_trade.SetAsyncMode(false); g_trade.SetDeviationInPoints((ulong)InpMaxDeviationPoints); UpdatePanel(); //--- Only this click path sends an order. Price zero requests market //--- execution; SL and TP remain zero. No retry is performed. ResetLastError(); bool sent = (InpDirection == PLAN_BUY ? g_trade.Buy(InpVolume, _Symbol, 0.0, 0.0, 0.0, g_attemptComment) : g_trade.Sell(InpVolume, _Symbol, 0.0, 0.0, 0.0, g_attemptComment)); int localError = GetLastError(); MqlTradeResult result = {}; g_trade.Result(result); g_attemptRequestId = result.request_id; g_attemptOrder = result.order; g_attemptDeal = result.deal; g_requestPending = false; if(result.deal > 0 || result.order > 0 || !IsDefiniteRequestFailure(result.retcode)) { g_tradeStatus = StringFormat("AWAITING RECONCILIATION: retcode %u; request %u", result.retcode, result.request_id); RefreshTradeLock(); } else { //--- A definite failure without execution identifiers establishes //--- rejection of this attempt. Unknown codes stay locked instead. g_tradeLocked = false; g_tradeStatus = StringFormat("REJECTED: %s (retcode %u)", g_trade.ResultRetcodeDescription(), result.retcode); g_reconciliationStatus = "Definite request failure"; } PrintFormat("Trading Cost Planner: sent=%s, request=%u, order=%I64u, " "deal=%I64u, retcode=%u, local error=%d, magic=%I64d. SL=0, TP=0.", sent ? "true" : "false", result.request_id, result.order, result.deal, result.retcode, localError, InpMagicNumber); UpdatePanel(); }Replace UpdatePanel() with the following version. A separate readiness row supplies the current blocking reason without erasing the latest execution outcome.
//+------------------------------------------------------------------+ //| Display pre-trade estimates, readiness, and the latest outcome | //+------------------------------------------------------------------+ void UpdatePanel() { //--- Collapsing removes the body objects. Continue updating runtime //--- state elsewhere, but do not write to rows that are not present. if(g_panelCollapsed) return; string direction = (InpDirection == PLAN_BUY ? "BUY" : "SELL"); //--- Start with unavailable text. Publish numeric quote values only //--- when the latest snapshot passed all quote validation checks. string quoteText = "Bid/Ask: unavailable"; string spreadText = "Current spread: unavailable"; if(g_quoteValid) { quoteText = "Bid " + DoubleToString(g_bid, _Digits) + " | Ask " + DoubleToString(g_ask, _Digits); spreadText = "Current spread: " + DoubleToString(g_spreadPoints, 1) + " points"; } //--- Preserve the sample count even when there are too few samples //--- to support the configured historical percentile. string historyText = "Historical spread: unavailable | " + IntegerToString(g_historySampleCount) + " samples"; if(g_historyAvailable) historyText = "Historical P" + IntegerToString(InpStressPercentile) + ": " + DoubleToString(g_historicalSpreadPoints, 1) + " points | " + IntegerToString(g_historySampleCount) + " samples"; //--- Numeric estimates and advisory comparisons use separate rows. //--- An unavailable estimate must not receive a threshold verdict. string currentText = "Current-spread estimate: unavailable"; string stressText = "Historical-scenario estimate: unavailable"; string currentStatus = "Current-spread comparison: unavailable"; string stressStatus = "Historical-scenario comparison: unavailable"; color currentColor = clrDimGray; color stressColor = clrDimGray; if(g_currentResultValid) { bool aboveThreshold = (g_currentResultPoints >= InpMinimumAfterSpreadPoints); currentText = StringFormat("Current-spread estimate: %.1f points", g_currentResultPoints); currentStatus = "Current-spread comparison: " + (aboveThreshold ? "AT OR ABOVE THRESHOLD" : "BELOW THRESHOLD"); currentColor = (aboveThreshold ? clrForestGreen : clrFireBrick); } if(g_stressResultValid) { bool aboveThreshold = (g_stressResultPoints >= InpMinimumAfterSpreadPoints); stressText = StringFormat("Historical-scenario estimate: %.1f points", g_stressResultPoints); stressStatus = "Historical-scenario comparison: " + (aboveThreshold ? "AT OR ABOVE THRESHOLD" : "BELOW THRESHOLD"); stressColor = (aboveThreshold ? clrForestGreen : clrFireBrick); } //--- Sample boundaries describe accepted observations. They do not //--- imply uninterrupted tick coverage between those timestamps. string firstText = "First sample: unavailable"; string lastText = "Last sample: unavailable"; string spanText = StringFormat("Requested: %d h | Sample span: unavailable", InpHistoryHours); if(g_historySampleCount > 0) { firstText = "First sample: " + TimeToString(g_historyFirstSampleTime, TIME_DATE | TIME_SECONDS) + " (server)"; lastText = "Last sample: " + TimeToString(g_historyLastSampleTime, TIME_DATE | TIME_SECONDS) + " (server)"; spanText = StringFormat("Requested: %d h | Sample span: %s", InpHistoryHours, FormatHistorySpan(g_historySpanSeconds)); } string historyStatusText = "History: " + g_historyStatus; if(g_historyTickLimitHit && !g_historyAvailable) historyStatusText += " | TICK CAP"; //--- Readiness is independent of the advisory threshold. //--- This check reports current prerequisites; it does not guarantee //--- that a later request will be accepted by the broker. string reason = ""; bool ready = CheckMarketTradeReady(reason); string readinessText = "Execution ready: click to request an order"; if(!ready) readinessText = "Execution blocked: " + reason; //--- Keep readiness separate from g_tradeStatus. A changing quote, //--- permission, or position must not erase the latest broker outcome. if(!SetPanelRow(0, StringFormat("Order size: %s %.8g lots", direction, InpVolume), clrBlack) || !SetPanelRow(1, StringFormat("Expected midpoint move: %.1f points", InpTargetMovePoints), clrBlack) || !SetPanelRow(2, quoteText, clrBlack) || !SetPanelRow(3, "Quote: " + g_quoteStatus, g_quoteValid ? clrForestGreen : clrFireBrick) || !SetPanelRow(4, spreadText, clrBlack) || !SetPanelRow(5, historyText, clrBlack) || !SetPanelRow(6, historyStatusText, g_historyAvailable ? clrForestGreen : clrFireBrick) || !SetPanelRow(7, currentText, currentColor) || !SetPanelRow(8, stressText, stressColor) || !SetPanelRow(9, "SL: none | TP: none", clrFireBrick) || !SetPanelRow(10, firstText, clrBlack) || !SetPanelRow(11, lastText, clrBlack) || !SetPanelRow(12, spanText, clrBlack) || !SetPanelRow(13, StringFormat("Advisory threshold: %.1f points", InpMinimumAfterSpreadPoints), clrBlack) || !SetPanelRow(14, currentStatus, currentColor) || !SetPanelRow(15, stressStatus, stressColor) || !SetPanelRow(16, "Pre-trade spread-only estimates in points", clrDimGray) || !SetPanelRow(17, readinessText, ready ? clrForestGreen : clrFireBrick)) return; //--- Button color and the readiness row use the same check. //--- A gray button remains clickable, but execution rechecks the //--- prerequisites and refuses to send when they are not satisfied. string button = g_panelPrefix + "EXECUTE"; string buttonText = StringFormat("%s %.8g LOTS AT MARKET", direction, InpVolume); ResetLastError(); if(!ObjectSetString(0, button, OBJPROP_TEXT, buttonText) || !ObjectSetInteger(0, button, OBJPROP_BGCOLOR, ready ? clrForestGreen : clrGray) || !ObjectSetString(0, g_panelPrefix + "TRADE_STATUS", OBJPROP_TEXT, "Last execution: " + g_tradeStatus)) { PrintFormat("Trading Cost Planner: Could not refresh trade controls. Error %d.", GetLastError()); return; } ChartRedraw(); }Replace OnInit(), OnTimer(), and OnChartEvent() with the following complete handlers. The timer refreshes history when due and reconciles execution; chart clicks are the sole source of trade requests.
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- Reject invalid configuration before creating chart objects //--- or enabling periodic updates. if(!ValidateInputs() || !ValidateMagicNumber() || !InitializeSymbolProperties()) return(INIT_PARAMETERS_INCORRECT); //--- Configure the request identifier before any click can submit //--- a trade. This setter does not return a success flag. g_trade.SetExpertMagicNumber((ulong)InpMagicNumber); g_panelPrefix = StringFormat("TCP_%I64d_", ChartID()); g_panelCollapsed = false; if(!BuildPanel()) return(INIT_FAILED); ResetLastError(); if(!EventSetTimer(5)) { PrintFormat("Trading Cost Planner: Could not start the timer. Error %d.", GetLastError()); DeletePanel(); return(INIT_FAILED); } g_timerActive = true; Comment(""); UpdateQuoteReadout(); return(INIT_SUCCEEDED); } //+------------------------------------------------------------------+ //| Timer function | //+------------------------------------------------------------------+ void OnTimer() { datetime serverTime = TimeTradeServer(); //--- Refresh tick history at most once per minute, including failed attempts if(serverTime > 0 && (g_lastHistoryAttempt == 0 || serverTime - g_lastHistoryAttempt >= HISTORY_REFRESH_SECONDS)) { g_lastHistoryAttempt = serverTime; RefreshHistoricalSpread(); } //--- Reconcile a confirmed fill before refreshing button availability RefreshTradeLock(); UpdateQuoteReadout(); } //+------------------------------------------------------------------+ //| Handle dashboard button clicks | //+------------------------------------------------------------------+ void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam) { if(id != CHARTEVENT_OBJECT_CLICK) return; if(sparam != g_panelPrefix + "TOGGLE" && sparam != g_panelPrefix + "EXECUTE") return; //--- Release the clicked button ResetLastError(); if(!ObjectSetInteger(0, sparam, OBJPROP_STATE, false)) { PrintFormat("Trading Cost Planner: Could not release panel button. Error %d.", GetLastError()); return; } if(sparam == g_panelPrefix + "TOGGLE") { SetPanelCollapsed(!g_panelCollapsed); return; } //--- Execute only from the expanded dashboard if(!g_panelCollapsed) ExecuteMarketOrder(); }
Add OnTradeTransaction() immediately after OnChartEvent(). Only a matching request event supplies additional request identifiers or a definite failure. Other transactions are reconciled through the timer's order/deal-history reads.
//+------------------------------------------------------------------+ //| Associate a broker request response with the retained attempt | //+------------------------------------------------------------------+ void OnTradeTransaction(const MqlTradeTransaction &trans, const MqlTradeRequest &request, const MqlTradeResult &result) { //--- Request and result structures are meaningful for REQUEST events. //--- Other transactions are reconciled by the existing timer. if(trans.type != TRADE_TRANSACTION_REQUEST || !g_tradeLocked || g_requestPending) return; bool matched = (g_attemptRequestId > 0 && result.request_id == g_attemptRequestId); if(g_attemptRequestId == 0) matched = (request.action == TRADE_ACTION_DEAL && request.symbol == _Symbol && request.magic == (ulong)InpMagicNumber && request.comment == g_attemptComment && request.type == g_attemptType && MathAbs(request.volume - g_attemptVolume) <= 1.0e-8); if(!matched) return; if(result.request_id > 0) g_attemptRequestId = result.request_id; if(result.order > 0) g_attemptOrder = result.order; if(result.deal > 0) g_attemptDeal = result.deal; if(IsDefiniteRequestFailure(result.retcode) && g_attemptOrder == 0 && g_attemptDeal == 0 && !g_fillConfirmed) { g_tradeLocked = false; g_tradeStatus = StringFormat("REJECTED: broker retcode %u", result.retcode); g_reconciliationStatus = "Matched request failure confirmed"; } //--- No trading or account-close operation is performed here. //--- The timer reads order/deal history after this short association. }
Reconciliation state is held in memory during this EA attachment. It does not provide restart recovery. Do not remove or reinitialize the EA while a request is unresolved; establish its broker outcome first. Use distinct magic numbers for independently operating instances. Position checks cannot provide an atomic reservation against another program trading between the check and submission.
Conclusion
We have built an EA that validates live quotes, measures historical spread samples, and displays two pre-trade estimates under different exit-spread assumptions. The panel also shows sample boundaries and availability statuses, so you can distinguish a supported calculation from insufficient or unavailable data. Compare each valid estimate with the advisory threshold; changing the threshold affects its comparison label, not the estimate itself.
Manual execution is separate from the planning model. Only a button click can request an order, and the threshold does not authorize or block it. The EA attaches no SL or TP, sends no automatic exit, and does not retry an uncertain request. Its reconciliation state is held in memory, so an unresolved request must be reconciled before you remove or reinitialize the EA.
Before relying on the assembled source, compile it in MetaEditor and verify its behavior on a connected demo account. Check quote and history availability, confirm that the two estimates agree when the historical percentile is no higher than the current spread, and confirm that changing the advisory threshold changes only the comparison labels. Also verify that click-time checks block an ineligible request and that any submitted order's outcome is reported separately from the pre-trade estimates.
Attachments
TradingCostPlanner.mq5 contains the complete EA source, including spread estimates, historical sample coverage, the collapsible panel, and manual market execution. Compare it with the article’s code checkpoints as you build the EA.
The source is also available in the Trading Cost Planner repository on Algo Forge.
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.