Did Your Scale Outs Actually Help? A Scale Out Value Analyzer in MQL5
Introduction
You scale out of a position because it feels disciplined: bank part of the profit, let the rest run, protect the trade from giving everything back. Months later, the strategy report shows a healthy net profit, and you assume the scaling helped. A standard MetaTrader report does not test that assumption. It only shows the blended result of all exits and does not show what would have happened if the whole position had been closed at any of those exit points.
This article builds the Scale-Out Value Analyzer, a native MQL5 tool. It reconstructs each position from closing-deal history and flags positions closed via multiple exits. It compares your actual result with three counterfactuals based on your realized exit prices: closing the full size at the first exit, at the last exit, or at your best realized exit. The comparison answers a question no built-in report asks directly: did scaling out add value, or did it just feel disciplined?
By the end, you will have a working tool that:
- Reconstructs positions from closing-deal history and detects which ones were closed through more than one exit.
- Measures the dollar value scaling out added or subtracted against two different counterfactual baselines.
- Reports what share of the best outcome available among your own exit prices was actually captured.
- Checks whether the result depends on a single lucky trade before trusting it.
- Combines the three findings into a configurable A+ to F score with concrete recommendations.
Everything runs natively inside MetaTrader 5. No external libraries, no Python, no AI.
Why the Naive Approach Is Not Enough
A strategy report's net profit is the sum of every closing deal, already blended. It cannot separate "the market moved further after I sold the last piece" from "the market reversed right after I sold the first piece," because by the time the report is generated, both outcomes look identical: one number per position. A trader who scales out of every position and a trader who never does can show the same net profit for entirely different reasons, and the report will not tell them apart.
The gap is not a lack of data. MetaTrader already stores every closing deal separately, including the price and volume of each partial exit. The missing piece is a comparison: what this same position would have returned if the trader had NOT scaled, using only the exit prices the trader actually achieved. That comparison is what the rest of this article builds.
A Scale-Out Position and Its Counterfactuals

Fig. 1. A scaled-out position and the three counterfactual outcomes the analyzer reprices from the trader's own exit points.
Concepts and Definitions
MetaTrader 5 history is deal-level, not position-level. A position that was closed through a single order produces one closing deal. A position closed through several partial exits produces several closing deals that share the same position identifier. This article calls each of those closing deals an exit leg. A position with exactly one exit leg was not a scaling decision; a position with two or more exit legs was.
For a position with exit legs 1..k, each leg has its own volume and its own net result (the deal's price-driven profit plus the commission and swap that specific deal actually paid). Dividing a leg's net result by its own volume gives a rate: the net result per lot that the trader actually achieved at that leg's exit price.
rate_i = (profit_i + commission_i + swap_i) / volume_i
The analyzer then reprices the position's full original volume at three of these rates to build three counterfactuals, each answering a different question:
- Cf_first: what the position would have returned if the entire volume had been closed at the first exit's rate—the cost of not simply taking full profit early.
- Cf_last: the return if the entire volume had instead been closed at the last exit's rate—the value of banking part of the profit early rather than riding the full size to the end.
- Cf_best: repricing the entire volume at the single best rate the trader actually achieved among all of its legs—the best outcome reachable without needing a better price than the trader actually got.
cf_x = rate_x * total_volume
Every counterfactual reuses a price the trader actually received; none of them require guessing what price the market would have offered at a different time. The value scaling added, against holding the full size to the last exit, is simply actual − cf_last. The efficiency of the scaling decision, against the best the trader's own exits offered, is actual/cf_best, bounded to 0-100%.
Design Choices
Three design choices affect the metrics. They are stated next to the formulas, not only in the limitations section.
The per-leg rate is computed from profit + commission + swap, not the price-driven profit alone, so every counterfactual is fully loaded with the real cost the trader paid on that leg. This assumes commission and swap scale linearly with volume, using the per-lot rate realized on the leg chosen for repricing. Commission is usually genuinely linear per lot at a given broker, so this is realistic; swap is closer to an approximation, since it is time-dependent rather than purely volume-dependent, and the counterfactual implicitly assumes the same holding duration as the leg being used for repricing.
The Value-Add Ratio aggregates as a ratio of sums, not an average of per-position percentages:
Ratio = Sum(actual − cf_last) / Sum(|cf_last|)
Summed across every scaled position, rather than averaging each position's own percentage. A position whose cf_last happens to be close to zero would otherwise produce a huge, meaningless percentage and dominate a simple average; summing first keeps every position's contribution proportional to its real size.
A position built from more than one entry deal (added to in stages before being closed) is detected from the exported entry count and excluded from scale-out analysis entirely, rather than approximated with a single blended entry price. The exclusion is counted and reported, not silent; see Limitations for why this is a scope boundary rather than a missing feature.
Architecture of the Scale-Out Value Analyzer
The solution is a single MQL5 script, ScaleOutValue.mq5, organized into five parts, plus a small companion script that exports the input file from the trade history:
- Data model and CSV loading: defines the structures that represent one exit leg, one reconstructed position, and one scoring result, plus a robust line-by-line CSV reader.
- Position reconstruction: sorts the raw rows and groups them into positions, separating single-exit positions, scaled positions, and the excluded multi-entry positions.
- Scale-out metrics engine: computes the three counterfactuals, the value-added figures, and the efficiency figure for every scaled position.
- Scoring: aggregates the per-position metrics into the Value-Add Ratio, the Scale-Out Win Rate, the average efficiency, the single-trade dependence check, and the final composite grade.
- Demonstration data: writes a built-in sample file on the first run, so the tool has something to analyze immediately.
The companion script, ScaleOutExport.mq5, reads the terminal's own closing-deal history with HistorySelect() and writes the CSV that ScaleOutValue.mq5 consumes, so a reader can run the analyzer on real trading history in two steps: run the exporter once, then run the analyzer.
The data model:
Every structure below carries an inline comment for every field. ExitLeg holds one closing deal; ScalePosition holds a reconstructed position built from one entry and one or more ExitLeg records; ExitRow mirrors one raw CSV line before it is grouped; ScaleMetrics and ScoreResult hold, respectively, the per-position and the aggregate output of the analysis; SampleRow is the literal shape of the built-in demonstration rows.
//--- one closing deal (exit leg) belonging to a reconstructed position struct ExitLeg { double volume; // Lot size closed by this deal double profit; // Price-driven profit of this deal (broker's DEAL_PROFIT) double commission; // Commission charged on this deal double swap; // Swap charged or credited on this deal }; //--- a reconstructed position: one entry plus one or more exit legs struct ScalePosition { long position_id; // MT5 position identifier string symbol; // Traded symbol string direction; // "buy" or "sell" datetime entry_time; // Entry time (earliest IN deal) double entry_price; // Entry price (earliest IN deal) double total_volume; // Total volume opened on this position ExitLeg legs[]; // Closing deals, one element per exit leg }; //--- one raw CSV data line, before it is grouped into a ScalePosition struct ExitRow { long position_id; // MT5 position identifier string symbol; // Traded symbol string direction; // "buy" or "sell" datetime entry_time; // Entry time repeated on every row of the position double entry_price; // Entry price repeated on every row of the position int entry_count; // Number of IN deals that built this position (>1 = unsupported) double total_volume; // Total volume opened on this position datetime exit_time; // Time of this specific closing deal double exit_price; // Price of this specific closing deal double exit_volume; // Lot size closed by this specific deal double exit_profit; // Price-driven profit of this specific deal double exit_commission; // Commission charged on this specific deal double exit_swap; // Swap charged or credited on this specific deal }; //--- computed scale-out metrics for one position closed in more than one exit struct ScaleMetrics { long position_id; // MT5 position identifier double actual; // Actual net result actually realized (sum of all legs) double cf_first; // Counterfactual: full volume repriced at the first-exit rate double cf_last; // Counterfactual: full volume repriced at the last-exit rate double cf_best; // Counterfactual: full volume repriced at the best-of-own-exits rate double value_vs_last; // actual - cf_last: the value scaling added versus holding to the last exit double value_vs_first; // actual - cf_first: the value scaling added versus taking it all at the first exit double efficiency; // actual / cf_best * 100, clamped to [0, 100]; -1.0 means not applicable }; //--- aggregate scoring output for the whole analyzed file struct ScoreResult { int n_scaled; // Number of positions closed in more than one exit int hits; // Number of those positions where scaling beat holding to the last exit double value_add_ratio; // Aggregate Value-Add Ratio, % double hit_rate; // Scale-Out Win Rate, % double avg_efficiency; // Average Scaling Efficiency, % double sub_value; // Value-Add dimension sub-score, 0-100 double sub_hit; // Hit-Rate dimension sub-score, 0-100 double sub_eff; // Efficiency dimension sub-score, 0-100 double composite; // Final Scale-Out Value Score, 0-100 string grade; // Letter grade derived from the composite score long stress_dropped_id; // Position removed by the single-trade dependence check double stress_ratio; // Value-Add Ratio recomputed without that position, % double stress_shift; // stress_ratio - value_add_ratio, in percentage points }; //--- one literal row of the built-in demonstration data set struct SampleRow { long position_id; // MT5 position identifier string symbol; // Traded symbol string direction; // "buy" or "sell" string entry_time; // Pre-formatted "YYYY.MM.DD HH:MM:SS" entry time double entry_price; // Entry price int entry_count; // Number of IN deals that built this position double total_volume; // Total volume opened on this position string exit_time; // Pre-formatted "YYYY.MM.DD HH:MM:SS" exit time of this leg double exit_price; // Price of this closing deal double exit_volume; // Lot size closed by this deal double exit_profit; // Price-driven profit of this deal double exit_commission; // Commission charged on this deal double exit_swap; // Swap charged or credited on this deal };
Module 1: CSV Parsing and Loading
The input file is a plain CSV with one row per closing deal, not per position: a position closed in three partial exits produces three rows that share the same PositionID, EntryTime, EntryPrice, EntryCount, and TotalVolume and differ only in the six Exit columns. The header is PositionID,Symbol,Direction,EntryTime,EntryPrice,EntryCount,TotalVolume,ExitTime,ExitPrice,ExitVolume,ExitProfit,ExitCommission,ExitSwap. ParseRow converts and validates one line; a line that fails to parse is skipped and counted, never fatal. LoadRows opens the file as plain text (FILE_TXT|FILE_ANSI), trims and skips blank lines and the header, and reports how many lines it had to skip.
//+------------------------------------------------------------------+ //| Parse one CSV data line into an ExitRow; returns false if the | //| line is malformed or fails validation | //+------------------------------------------------------------------+ bool ParseRow(string line, ExitRow &row) { string f[]; int n=StringSplit(line, ',', f); if(n!=13) return(false); for(int i=0; i<n; i++) { StringTrimLeft(f[i]); StringTrimRight(f[i]); } //--- convert every field; a field that fails to parse becomes 0 or an empty string and is caught below row.position_id = StringToInteger(f[0]); row.symbol = f[1]; row.direction = f[2]; row.entry_time = StringToTime(f[3]); row.entry_price = StringToDouble(f[4]); row.entry_count = (int)StringToInteger(f[5]); row.total_volume = StringToDouble(f[6]); row.exit_time = StringToTime(f[7]); row.exit_price = StringToDouble(f[8]); row.exit_volume = StringToDouble(f[9]); row.exit_profit = StringToDouble(f[10]); row.exit_commission = StringToDouble(f[11]); row.exit_swap = StringToDouble(f[12]); //--- reject a row whose required numeric or time fields did not parse or are out of range if(row.position_id<=0 || row.entry_time<=0 || row.exit_time<=0) return(false); if(row.total_volume<=0.0 || row.exit_volume<=0.0 || row.entry_price<=0.0 || row.exit_price<=0.0) return(false); if(row.direction!="buy" && row.direction!="sell") return(false); if(row.entry_count<1) return(false); return(true); }
//+------------------------------------------------------------------+ //| Load and validate every data row from the CSV file; malformed | //| lines are skipped and counted, not fatal | //+------------------------------------------------------------------+ int LoadRows(const string filename, ExitRow &rows[], int &skipped) { ResetLastError(); int handle=FileOpen(filename, FILE_READ|FILE_TXT|FILE_ANSI); if(handle==INVALID_HANDLE) { PrintFormat("ERROR: cannot open %s, error code %d.", filename, GetLastError()); return(-1); } int total=0; skipped=0; bool header_done=false; while(!FileIsEnding(handle)) { string line=FileReadString(handle); StringTrimLeft(line); StringTrimRight(line); if(line=="") continue; if(!header_done) { header_done=true; // first non-blank line is the CSV header, not data continue; } ExitRow row; if(ParseRow(line, row)) { total++; ArrayResize(rows, total); rows[total-1]=row; } else skipped++; } FileClose(handle); if(skipped>0) PrintFormat("WARNING: skipped %d malformed row(s) while reading %s.", skipped, filename); return(total); }
Module 2: Position Reconstruction
SortRows uses insertion sort by position ID and then by exit time. This keeps each position's rows contiguous and ordered by close time. Offline runs are small enough that insertion sort is simpler to verify than a generic library routine. BuildPositions then walks the sorted rows in one pass, grouping each run of equal position IDs into a ScalePosition. A position whose rows report more than one entry deal is counted in excluded_multi_entry and skipped, never merged into a single approximate entry.
//+------------------------------------------------------------------+ //| Sort rows by position id, then by exit time within a position, | //| so every position's rows end up contiguous and time-ordered | //+------------------------------------------------------------------+ void SortRows(ExitRow &rows[]) { int total=ArraySize(rows); for(int i=1; i<total; i++) { ExitRow key=rows[i]; int j=i-1; while(j>=0 && (rows[j].position_id>key.position_id || (rows[j].position_id==key.position_id && rows[j].exit_time>key.exit_time))) { rows[j+1]=rows[j]; j--; } rows[j+1]=key; } }
//+------------------------------------------------------------------+ //| Group sorted rows into positions; a position built from more | //| than one entry deal is counted and excluded, not silently merged | //+------------------------------------------------------------------+ int BuildPositions(const ExitRow &rows[], ScalePosition &positions[], int &excluded_multi_entry) { int total_rows=ArraySize(rows); int total_pos=0; excluded_multi_entry=0; int i=0; while(i<total_rows) { long pid=rows[i].position_id; int j=i; while(j<total_rows && rows[j].position_id==pid) j++; if(rows[i].entry_count>1) { excluded_multi_entry++; i=j; continue; } total_pos++; ArrayResize(positions, total_pos); int p=total_pos-1; positions[p].position_id = pid; positions[p].symbol = rows[i].symbol; positions[p].direction = rows[i].direction; positions[p].entry_time = rows[i].entry_time; positions[p].entry_price = rows[i].entry_price; positions[p].total_volume = rows[i].total_volume; int leg_count=j-i; ArrayResize(positions[p].legs, leg_count); for(int k=0; k<leg_count; k++) { positions[p].legs[k].volume = rows[i+k].exit_volume; positions[p].legs[k].profit = rows[i+k].exit_profit; positions[p].legs[k].commission = rows[i+k].exit_commission; positions[p].legs[k].swap = rows[i+k].exit_swap; } i=j; } return(total_pos); }
Module 3: Scale-Out Metrics Engine
ComputeMetrics is where the concepts above become numbers. A position with only one exit leg is skipped here; it was never a scaling decision. For positions with two or more legs, the function checks that exit volumes sum to the opened volume. This catches corrupted or incomplete exports instead of mis-scoring them. It then computes every leg's rate, the three counterfactuals, and the resulting value-added and efficiency figures described in Concepts and Definitions.
//+------------------------------------------------------------------+ //| Compute the scale-out counterfactuals for every position closed | //| in two or more exits; single-exit positions are not a scaling | //| decision and are skipped here | //+------------------------------------------------------------------+ int ComputeMetrics(const ScalePosition &positions[], ScaleMetrics &metrics[], int &excluded_bad_volume) { int total=ArraySize(positions); int n=0; excluded_bad_volume=0; for(int p=0; p<total; p++) { int leg_count=ArraySize(positions[p].legs); if(leg_count<2) continue; double vol_sum=0.0; for(int k=0; k<leg_count; k++) vol_sum+=positions[p].legs[k].volume; if(MathAbs(vol_sum-positions[p].total_volume)>0.0000001) { excluded_bad_volume++; continue; } double rates[]; ArrayResize(rates, leg_count); double actual=0.0; for(int k=0; k<leg_count; k++) { double net_leg=positions[p].legs[k].profit+positions[p].legs[k].commission+positions[p].legs[k].swap; actual+=net_leg; rates[k]=net_leg/positions[p].legs[k].volume; } double best_rate=rates[0]; for(int k=1; k<leg_count; k++) if(rates[k]>best_rate) best_rate=rates[k]; n++; ArrayResize(metrics, n); int m=n-1; metrics[m].position_id = positions[p].position_id; metrics[m].actual = actual; metrics[m].cf_first = rates[0]*positions[p].total_volume; metrics[m].cf_last = rates[leg_count-1]*positions[p].total_volume; metrics[m].cf_best = best_rate*positions[p].total_volume; metrics[m].value_vs_last = actual-metrics[m].cf_last; metrics[m].value_vs_first = actual-metrics[m].cf_first; metrics[m].efficiency = (metrics[m].cf_best>0.0) ? Clamp(actual/metrics[m].cf_best*100.0, 0.0, 100.0) : -1.0; } return(n); }
Module 4: Scoring
AggregateAndScore combines every scaled position's metrics into three bounded 0-100 sub-scores: the Value-Add dimension (the aggregate ratio mapped around a neutral midpoint of 50), the Hit-Rate dimension (the Scale-Out Win Rate itself, already 0-100), and the Efficiency dimension (the average efficiency, already 0-100). The weights are inputs. The composite score is normalized by the sum of weights, so only relative proportions matter. The same function runs the single-trade dependence check: it removes the single largest winning scale-out and recomputes the Value-Add Ratio to show whether the aggregate edge survives without its best contributor. This is a one-sided check by construction—removing a positive contributor can only lower the ratio—so the question it answers is by how much the ratio falls, not whether it falls.
//+------------------------------------------------------------------+ //| Clamp a value into a closed [lo, hi] range | //+------------------------------------------------------------------+ double Clamp(double x, double lo, double hi) { if(x<lo) return(lo); if(x>hi) return(hi); return(x); }
//+------------------------------------------------------------------+ //| Map a composite score to a letter grade using the configured | //| boundary inputs | //+------------------------------------------------------------------+ string GradeFromScore(double score, double gA_plus, double gA, double gB, double gC, double gD) { if(score>=gA_plus) return("A+"); if(score>=gA) return("A"); if(score>=gB) return("B"); if(score>=gC) return("C"); if(score>=gD) return("D"); return("F"); }
//+------------------------------------------------------------------+ //| Aggregate the per-position metrics into the three scoring | //| dimensions, the single-trade dependence check, and the final | //| composite score and grade | //+------------------------------------------------------------------+ void AggregateAndScore(const ScaleMetrics &metrics[], ScoreResult &result) { int n=ArraySize(metrics); result.n_scaled=n; double sum_value=0.0, sum_abs_cf=0.0, sum_eff=0.0; int hits=0, eff_n=0, best_idx=-1; for(int i=0; i<n; i++) { sum_value+=metrics[i].value_vs_last; sum_abs_cf+=MathAbs(metrics[i].cf_last); if(metrics[i].value_vs_last>0.0) hits++; if(metrics[i].efficiency>=0.0) { sum_eff+=metrics[i].efficiency; eff_n++; } if(best_idx==-1 || metrics[i].value_vs_last>metrics[best_idx].value_vs_last) best_idx=i; } result.hits = hits; result.value_add_ratio = (sum_abs_cf>0.0) ? sum_value/sum_abs_cf*100.0 : 0.0; result.hit_rate = (n>0) ? (double)hits/n*100.0 : 0.0; result.avg_efficiency = (eff_n>0) ? sum_eff/eff_n : 0.0; double stress_sum_value=sum_value; double stress_sum_cf=sum_abs_cf; result.stress_dropped_id=-1; if(best_idx>=0 && n>1) { result.stress_dropped_id = metrics[best_idx].position_id; stress_sum_value -= metrics[best_idx].value_vs_last; stress_sum_cf -= MathAbs(metrics[best_idx].cf_last); } result.stress_ratio = (stress_sum_cf>0.0) ? stress_sum_value/stress_sum_cf*100.0 : 0.0; result.stress_shift = result.stress_ratio-result.value_add_ratio; result.sub_value = Clamp(50.0+result.value_add_ratio*InpValueAddSensitivity, 0.0, 100.0); result.sub_hit = Clamp(result.hit_rate, 0.0, 100.0); result.sub_eff = Clamp(result.avg_efficiency, 0.0, 100.0); double wsum=InpWeightValueAdd+InpWeightHitRate+InpWeightEfficiency; result.composite = (wsum>0.0) ? (InpWeightValueAdd*result.sub_value+InpWeightHitRate*result.sub_hit+InpWeightEfficiency*result.sub_eff)/wsum : 0.0; result.grade = GradeFromScore(result.composite, InpGradeAPlus, InpGradeA, InpGradeB, InpGradeC, InpGradeD); }
Module 5: Demonstration Data
GenerateSampleFile runs only when InpFileName is not found in the terminal's MQL5\Files folder. It writes a fixed, hand-verified data set: 220 positions closed through more than one exit, 300 positions closed in a single exit, 6 positions built from more than one entry deal (to exercise the exclusion path), and one deliberately malformed row, so the robustness path has something real to skip and warn about instead of only being exercised on a reader's own messy export. The data set is literal and fixed rather than randomly generated, so every run of the demonstration produces the exact figures discussed in this article.
//+------------------------------------------------------------------+ //| Write the built-in demonstration CSV so the tool has something | //| to analyze the first time it runs, with no input file prepared | //+------------------------------------------------------------------+ void GenerateSampleFile(const string filename) { int handle=FileOpen(filename, FILE_WRITE|FILE_TXT|FILE_ANSI); if(handle==INVALID_HANDLE) { PrintFormat("ERROR: cannot create sample file %s, error code %d.", filename, GetLastError()); return; } FileWriteString(handle, "PositionID,Symbol,Direction,EntryTime,EntryPrice,EntryCount,TotalVolume,ExitTime,ExitPrice,ExitVolume,ExitProfit,ExitCommission,ExitSwap\r\n"); SampleRow g_sample[]= { {600001, "GBPUSD", "buy", "2025.10.15 07:00:00", 1.25959, 1, 1.00, "2025.10.16 13:00:00", 1.26172, 0.55, 117.00, -0.55, -0.11}, {600001, "GBPUSD", "buy", "2025.10.15 07:00:00", 1.25959, 1, 1.00, "2025.10.17 14:00:00", 1.25946, 0.45, -6.00, -0.45, -0.28}, {600002, "EURUSD", "sell", "2025.10.15 22:00:00", 1.08040, 1, 0.80, "2025.10.16 21:00:00", 1.07526, 0.43, 221.00, -0.43, -0.02}, {600002, "EURUSD", "sell", "2025.10.15 22:00:00", 1.08040, 1, 0.80, "2025.10.18 09:00:00", 1.08248, 0.37, -77.00, -0.37, -0.56}, {600003, "GBPUSD", "sell", "2025.10.16 16:00:00", 1.25580, 1, 0.70, "2025.10.17 22:00:00", 1.25566, 0.36, 5.00, -0.36, -0.09}, {600003, "GBPUSD", "sell", "2025.10.16 16:00:00", 1.25580, 1, 0.70, "2025.10.19 00:00:00", 1.24721, 0.34, 292.00, -0.34, -0.26}, {600004, "EURUSD", "buy", "2025.10.17 07:00:00", 1.08078, 1, 1.00, "2025.10.17 16:00:00", 1.08678, 0.50, 300.00, -1.00, 0.00}, {600004, "EURUSD", "buy", "2025.10.17 07:00:00", 1.08078, 1, 1.00, "2025.10.18 01:00:00", 1.07911, 0.30, -50.00, -0.60, -0.40}, {600004, "EURUSD", "buy", "2025.10.17 07:00:00", 1.08078, 1, 1.00, "2025.10.18 07:00:00", 1.07728, 0.20, -70.00, -0.40, -0.40}, {600005, "GBPUSD", "sell", "2025.10.18 03:00:00", 1.26166, 1, 0.30, "2025.10.18 05:00:00", 1.25731, 0.17, 74.00, -0.17, -0.34}, {600005, "GBPUSD", "sell", "2025.10.18 03:00:00", 1.26166, 1, 0.30, "2025.10.19 04:00:00", 1.26143, 0.13, 3.00, -0.13, -0.25}, {600006, "EURUSD", "buy", "2025.10.18 13:00:00", 1.08669, 1, 0.80, "2025.10.18 21:00:00", 1.08817, 0.46, 68.00, -0.46, -0.11}, {600006, "EURUSD", "buy", "2025.10.18 13:00:00", 1.08669, 1, 0.80, "2025.10.19 12:00:00", 1.09348, 0.34, 231.00, -0.34, -0.13}, {600007, "GBPUSD", "buy", "2025.10.18 23:00:00", 1.26502, 1, 0.70, "2025.10.20 03:00:00", 1.26771, 0.35, 94.00, -0.35, -0.45}, {600007, "GBPUSD", "buy", "2025.10.18 23:00:00", 1.26502, 1, 0.70, "2025.10.20 07:00:00", 1.26351, 0.35, -53.00, -0.35, -0.30}, {600008, "EURUSD", "sell", "2025.10.19 10:00:00", 1.08719, 1, 1.50, "2025.10.20 21:00:00", 1.08639, 0.50, 40.00, -0.50, 0.00}, {600008, "EURUSD", "sell", "2025.10.19 10:00:00", 1.08719, 1, 1.50, "2025.10.21 03:00:00", 1.08379, 1.00, 340.00, -1.00, -0.30} }; int total=ArraySize(g_sample); for(int i=0; i<total; i++) { string line=StringFormat("%I64d,%s,%s,%s,%.5f,%d,%.2f,%s,%.5f,%.2f,%.2f,%.2f,%.2f\r\n", g_sample[i].position_id, g_sample[i].symbol, g_sample[i].direction, g_sample[i].entry_time, g_sample[i].entry_price, g_sample[i].entry_count, g_sample[i].total_volume, g_sample[i].exit_time, g_sample[i].exit_price, g_sample[i].exit_volume, g_sample[i].exit_profit, g_sample[i].exit_commission, g_sample[i].exit_swap); FileWriteString(handle, line); } FileWriteString(handle, "900001,EURUSD,buy,2026.06.01 09:00:00,1.08500,1,N/A,2026.06.01 21:00:00,1.08600,0.50,60.00,-0.50,0.00\r\n"); FileClose(handle); PrintFormat("Generated a demonstration file: %s (%d sample rows plus 1 deliberately malformed row).", filename, total); }
The Main Script: Putting It All Together
OnStart ties every module together: generate the demonstration file if needed, load and sort the rows, reconstruct positions, compute the scale-out metrics, and—if enough scaled positions were found—aggregate them into the report below. The minimum sample size (InpMinScaledPositions, five by default) is enforced explicitly: with too few scaled positions, the script prints why and returns without producing a grade, rather than presenting a score built on too little data. A second, higher bar (InpRobustSampleSize, 200 by default) does not block the grade. Instead, it labels the result: below that count, OnStart prints the grade as PRELIMINARY; at or above it, OnStart confirms the sample meets the recommended minimum for a robust, non-preliminary result.
//+------------------------------------------------------------------+ //| Script program start function | //+------------------------------------------------------------------+ void OnStart() { Print("=== Scale-Out Value Analyzer ==="); if(!FileIsExist(InpFileName)) { PrintFormat("%s was not found in MQL5\\Files - generating a demonstration file to analyze.", InpFileName); GenerateSampleFile(InpFileName); } ExitRow rows[]; int skipped=0; int loaded=LoadRows(InpFileName, rows, skipped); if(loaded<=0) { Print("No usable rows were read - nothing to analyze."); return; } PrintFormat("Loaded %d row(s) from %s (%d skipped as malformed).", loaded, InpFileName, skipped); SortRows(rows); ScalePosition positions[]; int excluded_multi=0; int total_pos=BuildPositions(rows, positions, excluded_multi); PrintFormat("Reconstructed %d position(s); %d additional position(s) excluded (built from more than one entry deal).", total_pos, excluded_multi); ScaleMetrics metrics[]; int excluded_bad_vol=0; int n_scaled=ComputeMetrics(positions, metrics, excluded_bad_vol); if(excluded_bad_vol>0) PrintFormat("WARNING: %d position(s) excluded - exit volumes did not sum to the opened volume.", excluded_bad_vol); double pct_scaled=(total_pos>0) ? (double)n_scaled/total_pos*100.0 : 0.0; PrintFormat("%d of %d positions (%.1f%%) closed in more than one exit (scaled out).", n_scaled, total_pos, pct_scaled); if(n_scaled==0) { Print("No scaled positions were found - nothing to grade."); return; } if(n_scaled<InpMinScaledPositions) { PrintFormat("Only %d scaled position(s) found, below the configured minimum of %d - no grade produced.", n_scaled, InpMinScaledPositions); return; } ScoreResult result; AggregateAndScore(metrics, result); Print("--- Value-Add dimension (actual result vs. holding the full size to the last exit) ---"); PrintFormat("Aggregate Value-Add Ratio: %.2f%% (sum of value added across scaled positions / sum of |counterfactual|).", result.value_add_ratio); PrintFormat("Scale-Out Win Rate: %.1f%% (%d of %d scaled positions beat holding to the last exit).", result.hit_rate, result.hits, result.n_scaled); Print("--- Efficiency dimension (actual result vs. the best of the trader's own exit prices) ---"); PrintFormat("Average Scaling Efficiency: %.1f%%.", result.avg_efficiency); Print("--- Single-trade dependence check (largest winning scale-out removed) ---"); PrintFormat("Removing position #%I64d shifts the Value-Add Ratio from %.2f%% to %.2f%% (%.2f pts).", result.stress_dropped_id, result.value_add_ratio, result.stress_ratio, result.stress_shift); Print("--- Composite score ---"); PrintFormat("Sub-scores - Value-Add: %.1f Hit-Rate: %.1f Efficiency: %.1f.", result.sub_value, result.sub_hit, result.sub_eff); PrintFormat("Scale-Out Value Score: %.1f / 100 -> Grade %s.", result.composite, result.grade); if(n_scaled<InpRobustSampleSize) PrintFormat("Sample size: %d scaled position(s), below the recommended minimum of %d for a robust result - treat this grade as PRELIMINARY.", n_scaled, InpRobustSampleSize); else PrintFormat("Sample size: %d scaled position(s) meets the recommended minimum of %d for a robust, non-preliminary result.", n_scaled, InpRobustSampleSize); Print("This is a heuristic ranking score for comparison, not a statistically validated measure."); Print("--- Recommendations ---"); bool any_reco=false; if(result.hit_rate<InpHitRateAlertBelow) { PrintFormat("- Scaling out beat holding to the last exit in only %.1f%% of cases; review whether the final leg is closed on a rule or on impulse.", result.hit_rate); any_reco=true; } if(result.avg_efficiency<InpEfficiencyAlertBelow) { PrintFormat("- Average efficiency against your own best exit point is %.1f%%; tightening the trigger for the last leg could capture more of it.", result.avg_efficiency); any_reco=true; } if(result.value_add_ratio!=0.0 && MathAbs(result.stress_shift)>MathAbs(result.value_add_ratio)*InpStressDependenceAlert) { PrintFormat("- The aggregate edge depends partly on a single trade (position #%I64d); treat it with caution until more scaled positions accumulate.", result.stress_dropped_id); any_reco=true; } if(!any_reco) Print("- No specific concern crossed the configured thresholds."); }
Running It: Sample Report
Running ScaleOutValue.mq5 with no ScaleOutData.csv present in MQL5\Files triggers the built-in demonstration data set described above and prints the following report to the Experts tab:
=== Scale-Out Value Analyzer === ScaleOutData.csv was not found in MQL5\Files - generating a demonstration file to analyze. Generated a demonstration file: ScaleOutData.csv (772 sample rows plus 1 deliberately malformed row). WARNING: skipped 1 malformed row(s) while reading ScaleOutData.csv. Loaded 772 row(s) from ScaleOutData.csv (1 skipped as malformed). Reconstructed 520 position(s); 6 additional position(s) excluded (built from more than one entry deal). 220 of 520 positions (42.3%) closed in more than one exit (scaled out). --- Value-Add dimension (actual result vs. holding the full size to the last exit) --- Aggregate Value-Add Ratio: 38.91% (sum of value added across scaled positions / sum of |counterfactual|). Scale-Out Win Rate: 59.1% (130 of 220 scaled positions beat holding to the last exit). --- Efficiency dimension (actual result vs. the best of the trader's own exit prices) --- Average Scaling Efficiency: 45.9%. --- Single-trade dependence check (largest winning scale-out removed) --- Removing position #600203 shifts the Value-Add Ratio from 38.91% to 37.49% (-1.41 pts). --- Composite score --- Sub-scores - Value-Add: 96.7 Hit-Rate: 59.1 Efficiency: 45.9. Scale-Out Value Score: 70.2 / 100 -> Grade B. Sample size: 220 scaled position(s) meets the recommended minimum of 200 for a robust, non-preliminary result. This is a heuristic ranking score for comparison, not a statistically validated measure. --- Recommendations --- - Scaling out beat holding to the last exit in only 59.1% of cases; review whether the final leg is closed on a rule or on impulse. - Average efficiency against your own best exit point is 45.9%; tightening the trigger for the last leg could capture more of it.
Fig. 2. ScaleOutValue.mq5 report on the built-in demonstration data set.
Interpreting the Grade
A small worked example makes the two dimensions concrete. Position #600004 was opened with 1.00 lot and closed through three exits: 0.50 lot for a net leg result of $299.00 (a rate of $598.00 per lot), 0.30 lot for a net leg result of −$51.00 (a rate of −$170.00 per lot), and 0.20 lot for a net leg result of −$70.80 (a rate of −$354.00 per lot, and the last leg). The actual blended result is $177.20. Against cf_last (the full 1.00 lot repriced at −$354.00 per lot, i.e., −$354.00), scaling out added $531.20—by far the largest single contributor to the Value-Add Ratio, which is precisely why the single-trade dependence check exists. Against cf_best (the full 1.00 lot repriced at the best rate the position ever achieved, $598.00 per lot, i.e., $598.00), the same position is only 29.6% efficient: closing the whole 1.00 lot at the first exit, instead of splitting it three ways, would have made $598.00 instead of $177.20. The two dimensions genuinely disagree here, which is the point of reporting both: this position was an excellent decision relative to holding on to the bitter end and a poor one relative to the best price it actually touched.
In practice, a low scale-out win rate points to the discipline behind the final leg: is it closed on a predefined rule or on impulse when the trade starts to feel uncomfortable? A low average efficiency, even alongside a healthy Value-Add Ratio, points to the opposite problem: the early legs are being sold too early relative to the position's own best moment, as position #600004 illustrates above. The single-trade dependence check is the reason sample size matters. In the demonstration data, removing the single largest winner, position #600203, only moves the Value-Add Ratio from 38.91% to 37.49%, a 1.41-point shift on a base of 220 scaled positions (Fig. 2). On a much smaller sample, the same kind of removal can swing the ratio far more sharply—a Value-Add Ratio that collapses once its single largest winner is removed is a warning to keep collecting data before trusting the edge, not a reason to abandon scaling out altogether.
The Companion Exporter: ScaleOutExport.mq5
ScaleOutExport.mq5 reads the terminal's own account history directly and builds the CSV that ScaleOutValue.mq5 reads, so the analyzer can be run on real trading results in two steps: run the exporter once (optionally setting InpFrom and InpTo to a specific date range), then run the analyzer on the file it produces.
The script reads the deal history in two passes. The first pass visits every DEAL_ENTRY_IN deal of type DEAL_TYPE_BUY or DEAL_TYPE_SELL—this excludes balance, credit, correction, bonus, and interest entries—and aggregates them per position: the earliest fill's time and price, how many separate entry deals the position had, and the total volume opened. EntryInfo holds that aggregate, and FindEntry looks a position up in it by linear search, which is simple and fast enough for the deal counts an account's history typically holds.
//--- per-position entry aggregate built in the first pass over the deal history struct EntryInfo { long position_id; // MT5 position identifier string symbol; // Traded symbol string direction; // "buy" or "sell" datetime entry_time; // Time of the earliest IN deal seen for this position double entry_price; // Price of the earliest IN deal seen for this position int entry_count; // Number of IN deals seen for this position double total_volume; // Sum of the volumes of every IN deal for this position };
//+------------------------------------------------------------------+ //| Find the index of a position id in the entries array, or -1 | //+------------------------------------------------------------------+ int FindEntry(const EntryInfo &entries[], long position_id) { int total=ArraySize(entries); for(int k=0; k<total; k++) if(entries[k].position_id==position_id) return(k); return(-1); }
The second pass visits every closing deal—DEAL_ENTRY_OUT or DEAL_ENTRY_OUT_BY—and writes one CSV row per closing deal, referencing its position's cached entry aggregate. A position closed through several partial exits therefore produces several rows that share the same PositionID, which is precisely the granularity ScaleOutValue.mq5 expects.
//+------------------------------------------------------------------+ //| Script program start function | //+------------------------------------------------------------------+ void OnStart() { datetime to=(InpTo==0) ? TimeCurrent() : InpTo; datetime from=(InpFrom==0) ? D'2000.01.01' : InpFrom; if(!HistorySelect(from, to)) { PrintFormat("ERROR: HistorySelect failed, error code %d.", GetLastError()); return; } int total_deals=HistoryDealsTotal(); EntryInfo entries[]; int entry_total=0; //--- first pass: aggregate every IN deal per position (earliest fill time and price, deal count, opened volume). //--- deal-level granularity: a position opened in several fills is several IN deals sharing one position id for(int i=0; i<total_deals; i++) { ulong ticket=HistoryDealGetTicket(i); if(ticket==0) continue; long type=HistoryDealGetInteger(ticket, DEAL_TYPE); if(type!=DEAL_TYPE_BUY && type!=DEAL_TYPE_SELL) continue; // excludes balance, credit, correction, bonus, interest long entry=HistoryDealGetInteger(ticket, DEAL_ENTRY); if(entry!=DEAL_ENTRY_IN) continue; long pid = HistoryDealGetInteger(ticket, DEAL_POSITION_ID); double vol = HistoryDealGetDouble(ticket, DEAL_VOLUME); double price = HistoryDealGetDouble(ticket, DEAL_PRICE); datetime tm = (datetime)HistoryDealGetInteger(ticket, DEAL_TIME); string sym = HistoryDealGetString(ticket, DEAL_SYMBOL); string dir = (type==DEAL_TYPE_BUY) ? "buy" : "sell"; int idx=FindEntry(entries, pid); if(idx==-1) { entry_total++; ArrayResize(entries, entry_total); idx=entry_total-1; entries[idx].position_id = pid; entries[idx].symbol = sym; entries[idx].direction = dir; entries[idx].entry_time = tm; entries[idx].entry_price = price; entries[idx].entry_count = 0; entries[idx].total_volume = 0.0; } entries[idx].entry_count++; entries[idx].total_volume+=vol; if(tm<entries[idx].entry_time) // keep the EARLIEST in-deal as the reported entry { entries[idx].entry_time = tm; entries[idx].entry_price = price; } } if(entry_total==0) { Print("No BUY/SELL entry deals found in the requested range - nothing to export."); return; } int handle=FileOpen(InpOutputFile, FILE_WRITE|FILE_TXT|FILE_ANSI); if(handle==INVALID_HANDLE) { PrintFormat("ERROR: cannot create %s, error code %d.", InpOutputFile, GetLastError()); return; } FileWriteString(handle, "PositionID,Symbol,Direction,EntryTime,EntryPrice,EntryCount,TotalVolume,ExitTime,ExitPrice,ExitVolume,ExitProfit,ExitCommission,ExitSwap\r\n"); int rows_written=0; for(int i=0; i<total_deals; i++) { ulong ticket=HistoryDealGetTicket(i); if(ticket==0) continue; long type=HistoryDealGetInteger(ticket, DEAL_TYPE); if(type!=DEAL_TYPE_BUY && type!=DEAL_TYPE_SELL) continue; long entry=HistoryDealGetInteger(ticket, DEAL_ENTRY); if(entry!=DEAL_ENTRY_OUT && entry!=DEAL_ENTRY_OUT_BY) continue; long pid=HistoryDealGetInteger(ticket, DEAL_POSITION_ID); int idx=FindEntry(entries, pid); if(idx==-1) continue; double xvol = HistoryDealGetDouble(ticket, DEAL_VOLUME); double xprice = HistoryDealGetDouble(ticket, DEAL_PRICE); double xprofit = HistoryDealGetDouble(ticket, DEAL_PROFIT); double xcomm = HistoryDealGetDouble(ticket, DEAL_COMMISSION); double xswap = HistoryDealGetDouble(ticket, DEAL_SWAP); datetime xtime = (datetime)HistoryDealGetInteger(ticket, DEAL_TIME); string line=StringFormat("%I64d,%s,%s,%s,%.5f,%d,%.2f,%s,%.5f,%.2f,%.2f,%.2f,%.2f\r\n", entries[idx].position_id, entries[idx].symbol, entries[idx].direction, TimeToString(entries[idx].entry_time, TIME_DATE|TIME_SECONDS), entries[idx].entry_price, entries[idx].entry_count, entries[idx].total_volume, TimeToString(xtime, TIME_DATE|TIME_SECONDS), xprice, xvol, xprofit, xcomm, xswap); FileWriteString(handle, line); rows_written++; } FileClose(handle); PrintFormat("Wrote %d closing-deal row(s) for %d position(s) to %s.", rows_written, entry_total, InpOutputFile); Print("Granularity note: each row is one closing deal, not one position - a position closed through several partial exits produces several rows that share the same PositionID."); }
Running ScaleOutExport.mq5 on an account with trading history prints a short confirmation, for example:
Wrote 47 closing-deal row(s) for 35 position(s) to ScaleOutData.csv. Granularity note: each row is one closing deal, not one position - a position closed through several partial exits produces several rows that share the same PositionID.
Fig. 3. ScaleOutExport.mq5 confirmation after writing the CSV file.
Applicability and Limitations
The Value-Add Ratio and the Scale-Out Win Rate need a meaningful number of scaled positions to mean anything. InpMinScaledPositions defaults to five as a hard floor below which no grade is produced at all, but five is only enough to avoid a degenerate small-sample result, not enough to trust the number. InpRobustSampleSize adds a second, higher bar: 200 scaled positions by default. It does not block the grade but labels it PRELIMINARY below that count and confirms it as robust at or above it, so the reader always knows which situation they are in.
The demonstration data set in this article uses 220 scaled positions out of 520 closed positions in total, comfortably above that recommended minimum. That is why the sample report above (Fig. 2) is reported as robust rather than preliminary. The single-trade dependence check still matters at any sample size. At 220 scaled positions, removing the single largest winner shifts the demonstration's Value-Add Ratio by only 1.41 points. On a much smaller sample, the same check can move the ratio far more sharply—just the situation InpRobustSampleSize is meant to flag.
A position built from more than one entry deal is excluded from scale-out analysis entirely, not approximated; see Future Work for what a correct extension would need. The analyzer also only reprices the trader's own realized exit prices against each other—it does not replay price after any exit the way an MAE/M_E excursion analyzer does, so it cannot answer what a trailing stop or a fixed take-profit target would have produced instead. It complements, rather than replaces, that kind of forward replay, along with walk-forward testing and Monte Carlo trade-order resampling: this tool diagnoses a scaling habit that already happened. It does not simulate a different one.
Finally, every score here is a heuristic ranking for comparison, not a statistically validated measure of skill. Treat a single grade as a prompt to look at the underlying positions, not as a verdict.
Future Work
Three extensions would make the tool more complete:
- Multi-entry positions: Extend position reconstruction to a volume-weighted average entry price and time when a position was built from more than one entry deal, instead of excluding it, and flag when the entries themselves were spread far enough apart to change the interpretation of "the entry price."
- Alternate-exit replay: Combine this tool's counterfactual repricing with the M1 replay technique used by MAE/M_E excursion analysis to compare actual scaling not only against the trader's own exit points but also against a fixed trailing stop or fixed-R alternative.
- Per-symbol and per-setup breakdown: split the Value-Add Ratio and Efficiency by symbol or by a setup tag parsed from the trade comment to show whether scaling out helps consistently or only for specific instruments or entry types.
Conclusion
Scaling out of a position is a habit, not automatically an edge. The Scale-Out Value Analyzer turns that habit into a number by repricing every scaled position's own original volume at the exit rates the trader actually achieved, so the comparison never depends on a hypothetical price the market never offered. The result is a Value-Add Ratio, a Scale-Out Win Rate, and an Efficiency figure that, together with a single-trade dependence check, separate genuine discipline from a habit that merely feels disciplined.
The source code is available in the MQL5 CodeBase: Scale-Out Value Analyzer in the MQL5 CodeBase.
The following table describes the source files that accompany the article.
| File Name: | Description: |
|---|---|
| ScaleOutValue: | The main script. It loads the closing-deal CSV, reconstructs positions, computes the scale-out counterfactuals, and prints the composite Scale-Out Value Score with recommendations. |
| ScaleOutExport: | The companion script. It reads the terminal's own closing-deal history with HistorySelect() and writes the CSV that ScaleOutValue.mq5 reads. |
| MQL5.zip | is an archive whose root is the MQL5 folder, so it unpacks directly into the terminal data folder with every file in its correct place. Both scripts sit in MQL5\Scripts\ScaleOutValue\, ready to compile without moving anything. |
References:
- MetaQuotes, "MQL5 Reference: File Functions," MQL5 Documentation: Files;
- MetaQuotes, "MQL5 Reference: Trade Functions," MQL5 Documentation: Trading;
- MetaQuotes, "MQL5 Reference: Array Functions," MQL5 Documentation: Array;
- MetaQuotes, "Standard Constants: Deal Properties," MQL5 Documentation: Deal Properties;
- Scale-Out Value Analyzer source code, MQL5 CodeBase: Scale-Out Value Analyzer in the MQL5 CodeBase.
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.
Features of Custom Indicators Creation
MetaTrader 5 Machine Learning Blueprint (Part 21): Feature Importance Analysis
Features of Experts Advisors
The Dragonfly Algorithm (DA)
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use