
Here is a trading record. 238 closed trades over about four months. Win rate 54.6%. Profit factor 0.99. The equity curve climbs to +$455 by early May, gives it back over the next seven weeks, and claws most of it out again in July before ending where it started.
It looks like a strategy having an ordinary year. It is 238 draws from a distribution whose true mean I set to roughly zero before I generated it.
I built a free tool that reads MetaTrader statements and says that out loud, and this post is mostly about the statistics it runs rather than the tool. The numbers in it come from synthetic data I generated specifically so I would know the right answer in advance, which is the only way to test something like this honestly.
Disclosure: the tool is free and needs no account, and it sits on the site of a paid product I run. Links at the bottom. The math is the point.
Trading is unusually good at fooling you
Most fields where people look for an edge give you fast, cheap feedback. Trading gives you feedback that is slow, expensive, and dominated by noise. Three things combine badly.
The signal is tiny next to the variance. A decent retail strategy might make an average of $5 per trade with a standard deviation of $50 per trade. That ratio, not the size of the edge, decides how long it takes to see anything.
Cumulative sums of noise look like skill. This is the one that does the real damage. Add up fair coin flips, plot the running total, and you get something with trends, plateaus, drawdowns and recoveries. Over 500 flips the total typically wanders about 22 steps from zero in one direction or the other, because the spread of a random walk grows with the square root of its length. Every visual feature a trader reads as structure is produced in abundance by nothing at all.
An equity curve is exactly that cumulative sum. It is the most reached-for chart in trading and the most misleading, because it converts independent draws into a shape, and humans are extremely good at reading shapes and terrible at reading variance.
And then you only ever see the survivors. Take ten thousand people trading with no edge whatsoever. Their results centre on zero, so about sixteen hundred of them finish more than one standard deviation up. Those sixteen hundred have genuinely profitable records of real trades with real money. Some of them will conclude they have found something. Some of them will sell you a course about it. Nothing in their statement distinguishes them from the eight thousand four hundred others, because there is nothing to distinguish.
Statistics is not a nice-to-have on top of trading. It is the only thing standing between a trader and a convincing story.
How many trades you actually need
The quantity that matters is the standard error of the mean: the standard deviation of your per-trade results divided by the square root of how many you have.
const mean = results.reduce((a, b) => a + b, 0) / results.length;
const variance =
results.reduce((a, b) => a + (b - mean) ** 2, 0) / (results.length - 1);
const sd = Math.sqrt(variance);
const standardError = sd / Math.sqrt(results.length);
Take the $5 average and $50 standard deviation from earlier and watch what the sample size does:
| Trades | Standard error | Mean in standard errors | Roughly |
|---|---|---|---|
| 100 | $5.00 | 1.0 | one run in three looks this good by chance |
| 400 | $2.50 | 2.0 | one in twenty |
| 900 | $1.67 | 3.0 | one in four hundred |
A useful rule falls out of that. To resolve an edge, you need about (2 * sd / mean) ** 2 trades before the result sits two standard errors from zero. At a ratio of ten to one, that is four hundred trades. Most people form a strong opinion about a strategy somewhere around trade forty.
This is not a counsel of despair, it is a calibration. Forty trades is fine for finding out whether your execution works, whether your risk sizing is survivable, whether you can follow your own rules. It is not enough to learn whether the strategy makes money, and treating it as though it were is how people scale up right before the mean reverts.
Win rate and profit factor will not save you
Both are real numbers and both are quoted constantly, and neither answers the question on its own.
Win rate says nothing without the payoff ratio beside it. Ninety percent winners at a one to twenty payoff is a losing system that feels wonderful ninety percent of the time. Ten percent winners at fifty to one is a business. Win rate is the most psychologically available number in trading and close to the least informative, which is an unfortunate combination.
Profit factor (gross won divided by gross lost) does combine the two, but it inherits the whole sample size problem. A profit factor of 1.5 over forty trades is a number with an enormous error bar attached, and nobody ever prints the error bar.
Expectancy, the plain average result per trade, is the one that feeds directly into the test that follows. It is also the one nobody quotes, because it is usually a small and unexciting number.
Testing the edge
The question "is this edge real" has a standard form: how often would pure chance produce an average at least this far from zero, in a sample this size? That is a two-sided t-test, and on a few hundred trades the normal approximation is close enough.
// Abramowitz & Stegun 7.1.26 is plenty for a p-value rounded to 3 places.
function erf(x) {
const sign = x < 0 ? -1 : 1;
const z = Math.abs(x);
const t = 1 / (1 + 0.3275911 * z);
const y =
1 -
((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) *
t +
0.254829592) *
t *
Math.exp(-z * z);
return sign * y;
}
const tStat = mean / standardError;
const pValue = 2 * (1 - 0.5 * (1 + erf(Math.abs(tStat) / Math.SQRT2)));
On the record I opened with, the average trade is -$0.19 and the p-value comes back at 0.93. Ninety-three percent of random runs that size look at least that good. Correct answer, since I built it with no edge.
Two things this test does not do, and both matter more than the test itself.
It does not prove the absence of an edge. A high p-value on ninety trades means you have not measured anything yet, not that there is nothing there. Absence of evidence, in a regime engineered to produce very little evidence.
And it is not valid if the record you are testing was selected for looking good. If you tried fifty parameter sets and kept the best, the winner's p-value is meaningless, because you ran fifty tests and reported one. That is the multiple comparisons problem, and backtest overfitting is its natural habitat. The same applies to the trader who shows you their best account out of four. The test assumes you picked the sample before you saw the result, and in trading that assumption is broken more often than it holds.
Sequence luck
Here is the part that changes how people think about their own drawdowns.
Your equity curve is one ordering of your trades. The trades are what your strategy produced, but the order is an accident. Reshuffle them and the path changes completely while the endpoint stays exactly the same. Draw them with replacement instead of just reshuffling and you also get a distribution of endpoints.
function bootstrap(results, paths = 5000, horizon = results.length) {
const finals = new Float64Array(paths);
const maxDrawdowns = new Float64Array(paths);
for (let p = 0; p < paths; p++) {
let cum = 0, peak = 0, worst = 0;
for (let i = 0; i < horizon; i++) {
cum += results[(Math.random() * results.length) | 0];
if (cum > peak) peak = cum;
if (peak - cum > worst) worst = peak - cum;
}
finals[p] = cum;
maxDrawdowns[p] = worst;
}
return { finals: finals.sort(), maxDrawdowns: maxDrawdowns.sort() };
}
Thirty lines, and it answers a question no single equity curve can. Run it on that same record:
- The worst drawdown the account actually lived through: $534
- The median worst drawdown across five thousand reorderings: $602
- The ninety-fifth percentile: $1,225
- Share of reorderings that drew down deeper than the real account ever did: 60%
Sixty percent. The drawdown this trader survived, and would quite reasonably use to size their risk, is shallower than a coin flip among the paths their own trades can produce. The number to plan around is $1,225, and they have never seen anything like it.
This generalises. People calibrate risk against their lived experience, and lived experience is a single draw. Position sizing built on the drawdown you happen to have had is built on the part of the record that is pure sequence luck. The bootstrap is the cheapest possible correction for that, and it needs nothing you do not already have.
Costs move the mean and leave the variance alone
This is why costs are so much more dangerous than they look.
Spread, commission and swap subtract a near-constant amount from every trade. They shift the mean down and barely touch the standard deviation. So their effect is completely invisible in the noise, trade by trade, and absolutely decisive in the only number that compounds.
The record I have been using:
- Average trade before costs: +$0.92
- Commission and swap: -$1.11 per trade
- Average trade after costs: -$0.19
There is a real gross edge in there. It is smaller than the cost of trading. Nothing in the entry logic is broken, and no amount of work on the entry logic fixes it; the fixes are a cheaper broker, fewer trades, or longer holds. A trader looking at a flat equity curve will almost always go hunting in the signal, because the signal is the interesting part. The answer was in the fee column.
Where the result comes from
Totals hide structure. The same record, bucketed by the hour the trade was opened, puts -$330 in the small hours and +$234 in the afternoon. Bucketed by weekday, Tuesday makes $309 and Friday loses $291.
Usual caveat, with feeling: these are the same small samples sliced thinner, so each bucket has a wider error bar than the total. Slicing twenty ways and reporting the worst slice is the multiple comparisons problem again, wearing a different hat. Buckets are for generating a hypothesis you then test on data you have not looked at yet, not for concluding that you should stop trading Fridays.
They are still worth computing, because a bucket that is both large and lopsided is the one kind of finding that survives.
Parsing MetaTrader's exports
Enough statistics. The input to all of this is the HTML statement MetaTrader writes, and it is a more interesting parsing target than it has any right to be.
There are two layouts. MT4 writes a Closed Transactions table keyed by Ticket and Item. MT5 writes a Positions section keyed by Position and Symbol, and then appends Orders and Deals sections after it. Rather than sniff the platform first, I scan every row in the document, recognise either header by its column names, and read the rows that follow until the shape stops matching.
Four things bit me, two of them only after I built realistic fixtures and compared the two formats against each other.
Column names repeat. MT5's Positions header is Time, Position, Symbol, Type, Volume, Price, S/L, T/P, Time, Price, Commission, Swap, Profit. Both Time and Price appear twice, once for the open and once for the close, distinguished by nothing but document order. So the column map takes explicitly named columns first (Open Time, Close Time in MT4) and falls back to positional order for the duplicates.
Balance rows live inside the trade table. MT4 puts deposits and withdrawals in the middle of the closed transactions table, using colspan so the row has five cells where the trades have fourteen. My first version treated a column count mismatch as the end of the section, which meant a single deposit row at the top silently truncated the entire statement to zero trades. A mismatch has to mean "skip this row", never "this section ended".
But something still has to end a section. MT5's Deals header is fourteen columns where Positions is thirteen, within the tolerance I had just widened. Without a guard, every closed trade gets counted a second time from the deals log, with the columns shifted. The fix is that only a row that looks like a header ends a section, whether or not a usable column map comes out of it:
function looksLikeHeader(cells) {
let hits = 0;
for (const c of cells) if (HEADER_WORDS.has(normalise(c))) hits++;
return hits >= 3;
}
Numbers and dates are localised. Statements come out in the terminal's codepage, frequently not UTF-8, so decoding tries UTF-8 first and falls back to windows-1252 when the result is full of replacement characters. Thousands separators may be spaces, including non-breaking ones, and a localised build can emit a decimal comma. The rule that handles every case I found: whichever separator sits last and leaves one to three trailing digits is the decimal one.
Timestamps are their own trap. They are printed in broker server time with no offset, so I build them with Date.UTC from the printed components and read the hour back with getUTCHours. That way the hour-of-day buckets show the clock the trader actually saw, rather than silently shifting by the visitor's timezone. When the same data comes from an API as real instants instead, the chart says so on the axis. Two different clocks quietly mixed into one histogram is a bug that produces plausible output forever.
Browser only, and the one exception
Asking strangers to upload their complete trading history to your server is a lot to ask, and storing it is worse. So the upload path does not have a server. The file is read with the browser's own file reader, parsed with DOMParser, and analysed in the tab. Nothing is transmitted, which means the page can say nothing is transmitted without any asterisks.
That constraint turned out to shape the whole thing. The statistics, the bootstrap, the charts all had to be cheap enough to run on a phone, which ruled out anything heavy and ruled out a charting dependency. Five thousand bootstrap paths over a few hundred trades is a couple of million additions, which is tens of milliseconds, so the limit was never the math.
There is one exception. The tool can also pull closed positions from a trading API instead of a file, and that API serves no CORS headers, so a browser cannot call it. That path goes through a small relay that forwards the key to two read-only endpoints and neither logs nor stores it. I would rather have had no server at all, but the honest version of "we need a relay and here is exactly what it does" beats pretending the constraint does not exist.
The charts ended up as hand-written SVG for the same reason: no dependency, and the whole tool compiles into a single 42 KB route chunk that one of the site's forty-one pages loads. Everything else on the site is untouched by it, which I check at build time rather than hope for.
What the math assumes, and where it breaks
Every technique above rests on two assumptions, and both are breakable.
Independence. The t-test and the bootstrap both assume each trade is a separate draw. Grid and martingale systems violate this flagrantly: their trades are deliberately correlated, which is the entire mechanism, and resampling them scatters a structure that was load-bearing. Overlapping positions in correlated instruments break it more quietly. When independence fails, the test overstates its own confidence, so the p-values look better than they deserve.
Stationarity. Resampling your past trades assumes the future draws from the same distribution. Markets change regime, spreads widen, a broker changes execution. The bootstrap tells you what your trades imply about a world that keeps behaving like the one they came from, which is a narrower claim than it looks.
And the big one, again, because it is the one that gets everybody: none of this is valid on a record that was selected for looking good. Test the strategy you committed to, not the best of the ones you tried.
Try it
The tool is at fxsocket.com/tools/statement-analyzer. Drop in an MT4 or MT5 statement and it runs everything above: expectancy with the test against zero, profit factor, drawdown, the cost breakdown, the buckets, and the bootstrap with the drawdown percentiles. Free, no account, and the file stays in your browser.
If you do not trade MetaTrader, the pieces in this post are a hundred lines of plain JavaScript and port to whatever holds your trade log. The standard error calculation alone would change how a lot of people read their own records.
I would genuinely like to be argued with on two points. First, whether the bootstrap should resample trades or blocks of trades, since block resampling preserves some autocorrelation and I decided the added complexity was not worth it for most retail records. Second, whether a p-value is the right thing to show a trader at all, or whether a confidence interval on expectancy would land better. Comments are open.
Trading leveraged products carries a real risk of loss. Nothing in this post is financial advice.
Top comments (1)
The "60% of reorderings drew down deeper" line mixes two different resamples. Your code draws with replacement, which also lets the final equity wander (sd of the endpoint is about sd*sqrt(238), roughly $490 at a $32 per-trade sd). A pure reshuffle keeps the endpoint pinned at the real -$45 and only changes the path.
I checked with a stand-in of my own, not your data: 238 trades, mean -0.19, sd 32, 5,000 paths each. Median worst drawdown: 530 reshuffled vs 549 bootstrapped. 95th percentile: 797 vs 1,076. So the median barely moves, but the tail you plan risk around is partly endpoint variance, and the reshuffle is the cleaner "same trades, different luck" answer. Both assume the trades are independent, which a strategy that clusters in volatile weeks breaks.
Do you report both, or only the bootstrap? And does the tool show the reshuffle number next to it?