Every Failure Must Fail Toward Zero

Published: 2026/09/16 UTC Updated: 2026/09/16 UTC Permalink

Every bug so far has been survivable. A bad signal costs you one bad trade. A mis-set ATR floor costs you a month of no trades. Annoying, recoverable, visible.

Sizing is not like that. The code that answers how much is the only code in an EA that can take the account out in a single order, and it fails in a direction you will not notice, because an oversized trade looks exactly like a normal trade until it is the last one. So this layer is written to a different standard than the rest of the EA: every path through it that is not clearly correct returns zero. Not a default, not a minimum, not a best guess — zero, which means no trade.

What "risk 0.25%" actually has to compute

The user-facing idea is simple: risk a quarter of one percent of equity per trade. Turning that into a lot size is not. Everything below is Helpers/KurosawaRiskManager.mqh and Helpers/KurosawaExecutor.mqh in the public repository.

const double riskMoney = equity * (riskPercent / 100.0);

const double tickValue = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE);
const double tickSize  = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
const double pointSize = SymbolInfoDouble(symbol, SYMBOL_POINT);

// Money per 1 point per 1.0 lot
const double valuePerPointPerLot = tickValue * (pointSize / tickSize);

double lots = riskMoney / (slPoints * valuePerPointPerLot);

The middle line is the part people hardcode and regret. A point is not a tick, and neither is worth a fixed amount of money. Tick value is expressed in your account currency, so it varies with the symbol, the contract size and — for any pair your account currency is not part of — the current exchange rate. It differs between EURUSD and USDJPY, between two brokers on the same symbol, and on one symbol between this month and next.

Ask the terminal. Never write 10.0 for the value of a pip.

Now count the guards around that arithmetic. Equity non-positive, risk money non-positive, tick value zero, tick size zero, point zero, computed value-per-point zero — six separate early returns, every one of them return 0.0. Each is a case where the terminal could not tell us something we need, and not one of them substitutes a plausible value.

The asymmetry that matters

Two functions round a lot size onto the broker's volume grid. They are nearly identical, and the single difference between them is the most important line in the file.

The lenient one, used when you have asked for an explicit fixed lot:

if(v < vmin) v = vmin;   // an explicit fixed lot may be raised to the broker minimum
if(v > vmax) v = vmax;   // ...but never past the broker maximum
return v;

The strict one, used for risk-based sizing:

if(v < vmin) return 0.0;  // risk-based lot below broker min -> refuse (do NOT oversize)
return v;

Same situation — the lot we computed is smaller than the smallest the broker will accept. Opposite answers.

The reason is that the two numbers are different kinds of claim. A fixed lot is an instruction: you said 0.01, the broker's minimum is 0.1, you get 0.1, and you can see that you did. A risk-based lot is a budget, and rounding a budget up is not rounding — it is spending money you said you would not spend.

Work it through. Your stop is 400 points out, your risk budget allows 0.004 lots, the broker's minimum is 0.01. Round up "just to the minimum" and you have taken two and a half times the risk you configured — with no warning, a clean log, and a realised risk per trade that nobody chose.

This happens systematically on small accounts and wide stops — which is to say, on exactly the accounts that can least afford it. The code is blunt about the consequence of refusing instead:

// Risk-based sizing is fail-safe: it never falls back to a fixed/min lot.
if(riskPercent <= 0.0)
   return 0.0; // risk sizing requested but no risk% -> stand down

const double raw = CalcLotRawByRiskPoints(symbol, slPoints, riskPercent, maxLotCap);
if(raw <= 0.0)
   return 0.0; // sizing data unavailable / calc failed -> stand down (no fixed fallback)

Note what is not there: a fallback to fixedLot. The two modes never rescue each other. If you asked for risk-based sizing and it cannot be done, the EA does not trade — that is the entire feature.

The cost of that is real, so it is paid out loud rather than in silence:

PrintFormat("EXEC_NO_VOLUME sym=%s slPts=%.1f riskPct=%.2f fixedLot=%.2f"
            " - cannot size a lot within the risk budget, skipping signal",
            sym, slPts, cfg.riskPercent, cfg.fixedLot);

Because "my EA never trades" is a support ticket, and "my EA quietly traded four times the intended size for six months" is an obituary. Fail toward zero, but say that you did — module 2's block counters, applied where being silent costs the most.

The bug that eats an entire volume step

Here is a line that looks like superstition and is not:

const double steps = MathFloor(MathMin(vmax, vol) / step + 1e-8);

That 1e-8 is load-bearing. In binary floating point, 0.29 / 0.01 does not evaluate to 29. It evaluates to 28.999999999999996, and MathFloor of that is 28 — so an explicit fixed lot of 0.29 trades 0.28, forever, on every order the EA ever sends.

Neither 0.29 nor 0.01 is exactly representable as a double, so the division lands a hair below the integer it should be. With a volume step of 0.1 the same effect is worse: 0.3 / 0.1 is 2.9999999999999996, which floors to 2, and your 0.3 lot becomes 0.2 — a 33% sizing error out of a line of code containing no mistake.

On the risk-based path it is worse still. Where a broker's step equals its minimum, a computed lot that should have landed exactly on the minimum floors to zero, the strict normaliser correctly refuses it, and the EA stands down for a reason that appears nowhere — the trade, as the comment puts it, "is refused with nothing in the log to explain why."

The rule is worth carrying out of MQL5 entirely: never floor a floating-point quotient you expect to be a whole number. Add an epsilon first, or work in integers.

The broker moves your stop after you sized the trade

This is the one almost nobody gets right, and it is invisible in a backtest on a friendly broker.

Before an order goes out, its stop is validated against the broker's minimum placement distance — and that validator can only push a stop further from entry. Its own signature says so: may be widened, never tightened.

Which is correct, and which silently breaks the sizing you just did. Volume was computed for the stop distance you asked for. If the broker widens that stop, the volume no longer matches it:

// EnsureStopsLevel can only push the stop FURTHER from entry, never
// closer. Volume was sized for the requested slPts, so sending a widened
// stop on that volume risks proportionally more than riskPercent -- e.g.
// a 40-point stop clamped to 100 on a broker with STOPS_LEVEL=100 risks
// 2.5x. Re-size from the distance actually being sent, and stand down if
// it cannot be sized inside the risk budget.

A 40-point stop clamped to 100 points is two and a half times the risk you configured — on every trade, on that broker, forever. Your risk percent has quietly stopped describing anything. And a scalping strategy on a broker with a wide stops level does this on all of its trades, not some of them.

The fix is to re-size against the distance actually being sent, not the one you wanted:

double sendVol = vol;
const double effSlPts = MathAbs(entry - sl) / pt;

if(effSlPts > slPts * 1.0001)
{
   sendVol = Risk_CalcTradeVolume(
      sym, effSlPts, cfg.useRiskSizing, cfg.riskPercent, cfg.fixedLot, cfg.maxLotCap
   );

   if(sendVol <= 0.0)
   {
      PrintFormat("EXEC_STOP_WIDENED sym=%s reqSL=%.1f effSL=%.1f pts"
                  " - cannot size within risk, standing down",
                  sym, slPts, effSlPts);
      diag_block_stops++;
      return false;
   }

   PrintFormat("EXEC_RESIZED sym=%s reqSL=%.1f effSL=%.1f pts vol %.2f -> %.2f",
               sym, slPts, effSlPts, vol, sendVol);
}

Three details are deliberate. The 1.0001 tolerance keeps float noise from triggering pointless recalculation. The resize is logged with both distances, so a broker that constantly widens your stops shows up as a pattern in the log rather than as a mysteriously disappointing equity curve. And if the wider stop cannot be sized inside the budget at all, the trade does not go out.

The underlying rule: anything computed from a value the broker is allowed to change must be recomputed after it changes. Sizing from an intention rather than from what is actually being sent is the same class of error as module 1's shift 0 — reasoning about a number that has not settled yet.

And now the part that matters most

Everything in this module is live code. The half of it that matters most is switched off.

Look at what the real preset files set, rather than at what the source defaults say:

; VALIDATION SIZING: InpUseRiskSizing=false, InpFixedLot=0.01.
InpUseRiskSizing=false||false||0||true||N
InpRiskPercent=0.20||0.20||0||0.20||N
InpFixedLot=0.01||0.01||0||0.01||N

The source header sets InpUseRiskSizing = true. All sixteen live and candidate presets run with it false, at the broker minimum of 0.01 lots. Not most of them — every one. You can check that claim yourself in the preset catalogue, or against /api/presets, which publishes the field for every preset.

Which has a consequence worth stating plainly: with a fixed lot, the re-sizing above is inert. Risk_CalcTradeVolume ignores the stop distance on that path and returns the same 0.01 either way, so a broker-widened stop really does mean more risk. At one hundredth of a lot it means a few cents of it — which is the point. Minimum lot is not a substitute for the machinery, it is what you run while the machinery is unproven.

There are two reasons for it, and both generalise.

An edge has to earn size. Risk-based sizing multiplies whatever your strategy actually does. If the edge is real, it compounds it; if the edge is a regime that is quietly ending, it compounds that instead. Minimum lot is how a strategy runs in public while it accumulates the live evidence that would justify anything larger — the Tokyo fix is the only family here to have cleared the promotion gate, and it is still trading 0.01 — and as module 3 said of the ATR floor, a number that is gathering data is not the same as a number that has been validated.

Fixed lot is also the honest setting for a validation backtest. With risk sizing on, each trade's size depends on the equity left behind by the trades before it, so the curve reflects the order of the results as much as the signals — shuffle the same trades and the final number changes. Fixed lot gives every trade equal weight, so what you measure is the strategy and nothing else. Turn sizing on once you believe the edge, not while you are testing whether it exists.

Which is the claim module 3 closed on. Sizing is the one layer where being conservative has no downside, because the upside it declines is a bigger win on an edge you have not proved, and the downside it declines is the account.

What you should have now

A lot size derived from the terminal's own tick values rather than a hardcoded pip, a hard rule that risk-based sizing refuses rather than rounds up, an epsilon before every volume floor, a resize after the broker touches your stop, and a log line every time the EA declines to trade. Plus a healthy suspicion of any EA whose risk percent is a setting nobody has ever checked against a real fill.

Next module: the layers that say no — the session, spread, cooldown, loss-streak and daily-loss gates, the account-level caps that exist because seven charts on one account can sell the yen three times at once, and the switch that pauses a strategy when its own last thirty trades say the edge has stopped working.

If this helped your EA work, share it.
X Facebook LinkedIn

Keisuke Kurosawa
Hello

Comments

0
No comments yet.

To add a comment, please log in.
Share
https://1kpips.com/en/blog/position-sizing
Categories
Learn
Tags
MQL5, MT5, position sizing, risk management, expert advisor, lot size

Related Articles

Next step
Save this idea into your EA: add a session filter, then backtest with and without it to see the difference.