Position Sizing in MQL5: Nine Risk Bases and Why You Should Not Trust SYMBOL_TRADE_TICK_VALUE

Position Sizing in MQL5: Nine Risk Bases and Why You Should Not Trust SYMBOL_TRADE_TICK_VALUE

3 November 2026, 02:31
Yang Yu Qun
0
5

Introduction

"Risk 1% per trade" is the most common risk rule there is. The rule itself is simple. Turning it into a lot size is where things go wrong:

  • The value of one point is different for every symbol and every broker. Rules of thumb like "1 pip = $10" stop working the moment you leave the major forex pairs.
  • Even the broker's own SYMBOL_TRADE_TICK_VALUE can be wrong. Because of it, one of my tools calculated a gold position that was 10 times too large.
  • The lot has to be rounded to the volume step. Round in the wrong direction and every trade carries a little more risk than you asked for.
  • When the budget is below the minimum lot, a calculator happily shows "0.01" without telling you that the trade now risks more than your budget.
  • And "1%" of what? Balance, equity, free margin, or the balance at the start of the day? For prop firm challenges these give different answers.

This post builds a risk-based lot calculator in MQL5 step by step: one formula, a point value you can trust, nine risk bases, rounding and clamping rules, and a self-check against the terminal. Two of the problems described here were real bugs in my own tools. I show the broken code, the fix, and the test results.

All code is collected in the script RiskLotCalc.mq5 , listed in full at the end of the post. It only calculates and prints to the Experts log. It never opens or modifies trades.

1. One formula for every symbol

Position sizing comes down to one line:

lot = risk budget / (stop loss in points × value of 1 point for 1 lot)

The hard part is the last term, the point value: how much money one point of price movement is worth for one lot, in account currency. Do not derive it yourself from contract size and exchange rates. The terminal can give it to you in two ways.

1.1 The usual way: the symbol specification

Almost every example on the web, including my own earlier code, does this:

double PointValueFromTickValue(const string sym)
{
   const double tick_value = SymbolInfoDouble(sym,SYMBOL_TRADE_TICK_VALUE);
   const double tick_size  = SymbolInfoDouble(sym,SYMBOL_TRADE_TICK_SIZE);
   const double point      = SymbolInfoDouble(sym,SYMBOL_POINT);
   if(tick_value <= 0.0 || tick_size <= 0.0 || point <= 0.0)
      return 0.0;
   return tick_value * point / tick_size;
}
  • SYMBOL_TRADE_TICK_VALUE : profit or loss of 1 lot when price moves by one tick, in account currency.
  • SYMBOL_TRADE_TICK_SIZE : the minimal price change.
  • SYMBOL_POINT : one unit of the last quoted digit.

Why multiply by point / tick_size ? On most forex pairs and on gold, tick_size == point , so using tick_value directly as the point value happens to work. On some indices it does not. Here are three real specifications from MetaQuotes-Demo, read on 26 October 2026:

Symbol Point Tick size Tick value Contract size Profit currency
EURUSD 0.00001 0.00001 1.0 100000 USD
XAUUSD 0.01 0.01 0.1 100 USD
HK50 0.1 0.5 0.5 1 HKD

HK50 is quoted to 0.1, but its price moves in steps of 0.5. Using tick_value as the point value here would overstate it 5 times.

The other two bold numbers are the subject of the next section.

1.2 The specification can be wrong

The function above ran for months in two of my own tools, a lot calculator and a trading panel. Then I tested XAUUSD on MetaQuotes-Demo. The contract size is 100 ounces and the tick size is 0.01, so one tick on one lot should be worth $1.00. The server reports a tick value of 0.1.


Image 1. XAUUSD specification on MetaQuotes-Demo: Contract size 100, Tick size 0.01, Tick value 0.1

The consequence is easy to calculate. With a $100 risk and a 300-point stop:

point value from tick_value = 0.1  →  lot = 100 / (300 × 0.1) = 3.33
real point value            = 1.0  →  lot = 100 / (300 × 1.0) = 0.33

If 3.33 lots hit the stop, the loss is about $1,000. On a $10,000 account you think you are risking 1%, and you are actually risking 10%. The trading panel opens orders, so it would have sent exactly that order.

Actual profit and loss on this server is settled by contract size. I had already replayed historical trades on the same server with a contract size of 100 and every trade matched. So the wrong number is the tick value, not the contract.

XAUUSD is not the only case. On the same server HK50 has a contract size of 1 and its profit currency is the Hong Kong dollar. The tick value gives a point value of 0.1, while the terminal's profit calculation gives 0.01275 USD. The difference is about 7.8 times, which is the HKD/USD exchange rate. It looks as if the tick value is in Hong Kong dollars and was never converted to the account currency. This time the lot comes out smaller, which is the safe direction, but it is still wrong.

1.3 A better way: ask the terminal how it settles profit

OrderCalcProfit() uses the same calculation that MetaTrader 5 applies to real deals. It handles the symbol's calculation mode and the conversion to account currency. So ask it how much 1 lot loses over 1000 points, and divide by 1000:

double PointValuePerLot(const string sym)
{
   const double fallback = PointValueFromTickValue(sym);
   const double point = SymbolInfoDouble(sym,SYMBOL_POINT);
   const double price = SymbolInfoDouble(sym,SYMBOL_BID);   // last known quote when the market is closed
   const double probe = 1000.0;

   double loss = 0.0;
   if(point <= 0.0 || price <= probe * point ||
      !OrderCalcProfit(ORDER_TYPE_BUY,sym,1.0,price,price - probe * point,loss))
      return fallback;
   if(loss >= 0.0)
   {
      PrintFormat("%s: OrderCalcProfit returned %.2f (conversion rate not ready?) - falling back to tick_value, run again shortly",
                  sym,loss);
      return fallback;
   }

   const double pv = -loss / probe;
   if(fallback > 0.0 && MathAbs(pv - fallback) / pv > 0.05)
      PrintFormat("%s: point value from OrderCalcProfit %.5f differs from broker tick_value %.5f - using %.5f",
                  sym,pv,fallback,pv);
   return pv;
}

The design decisions:

  • OrderCalcProfit is the primary source, tick value is only a fallback. The fallback is used only when there is no quote or the call fails.
  • Report a disagreement. If the two values differ by more than 5%, print one line to the log. Then the user can see that the broker's specification has a problem.
  • Probe with 1000 points, not 1. The loss over a single point is tiny, and dividing it back amplifies rounding errors.
  • Calculate once and cache, but refresh. Do not call it on every tick. Recalculate when the symbol changes and on a timer (section 6 explains why a one-time calculation is not enough).

Example: balance $10,000, risk 1% = $100, EURUSD stop 300 points (30 pips), point value 1.00:

lot = 100 / (300 × 1.00) = 0.333… → 0.33 lots → actual risk = 0.33 × 300 × 1.00 = $99

XAUUSD with a 300-point stop ($3.00 in price) has the same real point value of 1.00, so it also gives 0.33 lots.

2. One percent of what? Nine risk bases

The risk budget in the formula defines your risk policy. My tools support nine ways to define it:

Mode Risk budget When to use it
Fixed money the amount you enter a fixed maximum loss per trade
% of balance Balance × % the classic fixed-fractional rule
% of equity Equity × % more conservative when positions are open: floating losses shrink the base
% of free margin Free margin × % many open positions, protecting margin
% of custom base your base × % after withdrawals, or sizing by a prop account's initial size
% of balance at day start balance at 00:00 today × % prop firm rules based on the day's starting balance
% of balance at week start balance at Monday 00:00 × % weekly risk limits
% of balance at month start balance on the 1st at 00:00 × % monthly risk limits
Manual lot reverse: lot in, risk out checking how much a given lot really risks

How different are balance and equity? With a balance of $10,000 and an open position at −$1,500:

  • 1% of balance → $100
  • 1% of equity → $85

The deeper the floating loss, the more an equity-based rule automatically reduces new positions.

The code is a single switch :

double RiskBudget(const ENUM_RISK_BASE mode)
{
   switch(mode)
   {
      case RISK_FIXED_MONEY:     return InpRiskMoney;
      case RISK_PCT_BALANCE:     return AccountInfoDouble(ACCOUNT_BALANCE)     * InpRiskPct / 100.0;
      case RISK_PCT_EQUITY:      return AccountInfoDouble(ACCOUNT_EQUITY)      * InpRiskPct / 100.0;
      case RISK_PCT_FREE_MARGIN: return AccountInfoDouble(ACCOUNT_MARGIN_FREE) * InpRiskPct / 100.0;
      case RISK_PCT_CUSTOM:      return InpCustomBase                          * InpRiskPct / 100.0;
      case RISK_PCT_PREV_DAY:    return BalanceAsOf(DayStart())                * InpRiskPct / 100.0;
      case RISK_PCT_PREV_WEEK:   return BalanceAsOf(WeekStart())               * InpRiskPct / 100.0;
      case RISK_PCT_PREV_MONTH:  return BalanceAsOf(MonthStart())              * InpRiskPct / 100.0;
      default:                   return 0.0;
   }
}

The first five read account properties directly. The last three need the balance at a moment in the past, and MQL5 has no function for that.

3. Balance at a past moment: work backwards

The idea: balance at time T = current balance − every balance change after T.

double BalanceAsOf(const datetime since)
{
   const double bal = AccountInfoDouble(ACCOUNT_BALANCE);
   if(since <= 0 || !HistorySelect(since,TimeCurrent()))
      return bal;
   double delta = 0.0;
   const int n = HistoryDealsTotal();
   for(int i = 0; i < n; i++)
   {
      const ulong ticket = HistoryDealGetTicket(i);
      if(ticket == 0)
         continue;
      delta += HistoryDealGetDouble(ticket,DEAL_PROFIT)
             + HistoryDealGetDouble(ticket,DEAL_SWAP)
             + HistoryDealGetDouble(ticket,DEAL_COMMISSION);
   }
   return bal - delta;
}

Three details matter:

  1. Subtract profit, swap and commission. Using profit alone drifts on accounts with high commissions.
  2. Deposits and withdrawals are rolled back too. They appear in the history as DEAL_TYPE_BALANCE deals, with the amount in DEAL_PROFIT . If you deposit $5,000 at noon, "balance at day start" is still the figure before the deposit, which is what prop firm rules usually mean. If you want trading results only, filter by DEAL_TYPE .
  3. Do not run it on every tick. HistorySelect() is not free. Calculate it at start-up and when the day changes, then cache it.

The period start is in server time. TimeCurrent() returns server time, so DayStart() is midnight on the server. Many prop firms reset their day in their own time zone, which is not necessarily the broker's midnight. Before using the day-start base to follow a prop firm rule, check when that firm's day actually starts. The rules also differ between firms: some use balance, some use the higher of balance and equity.

In the test in section 7 the account balance is $99,822.23. All three historical bases came out as $100,000: the difference is exactly the result of the trades closed earlier that day.

4. Rounding and clamping: where extra risk creeps in

The formula produces a raw lot such as 0.3333. The broker accepts only multiples of SYMBOL_VOLUME_STEP , between SYMBOL_VOLUME_MIN and SYMBOL_VOLUME_MAX . Three rules apply.

Rule 1: always round down. Rounding to nearest turns 0.335 into 0.34 and adds risk to every trade. A risk tool should never go over budget:

double res = MathFloor(lot / step + 1e-8) * step;

(The + 1e-8 is explained in section 5.)

Rule 2: say so when the minimum lot overrides the budget. Small accounts hit this constantly:

balance $1,000, risk 0.5% = $5, XAUUSD stop 800 points, point value 1.00
raw lot = 5 / (800 × 1.00) = 0.00625 → below the 0.01 minimum

There are two options: refuse, or raise the lot to the minimum. My tools raise it, because the user will usually trade anyway, and show a warning at the same time: this trade really risks 0.01 × 800 × 1.00 = $8, which is 0.8% of the balance and 1.6 times the budget.

Rule 3: show the actual risk, not the budget. After rounding and clamping, calculate the risk again from the final lot:

const double risk = lot * sl_points * point_value;   // not the budget

Many calculators show the number you typed ($5) in the risk field rather than what the trade will actually lose ($8). The difference is one line of code.

5. The second real bug: a floating-point trap that loses one step

The + 1e-8 in rule 1 comes from another bug I fixed. The original line was:

double res = MathFloor(lot / step) * step;   // buggy

It looks correct, but 0.29 cannot be represented exactly in binary floating point:

0.29 / 0.01 = 28.999999999999996  → MathFloor → 28 → 0.28
0.57 / 0.01 = 56.99999999999999   → 0.56
1.13 / 0.01 = 112.99999999999999  → 1.12
2.30 / 0.01 = 229.99999999999997  → 2.29

With a step of 0.01, 125 of the 1000 lot sizes from 0.01 to 10.00 lose one step. In risk-based sizing this only costs 0.01 lot, in the safe direction, so it is easy to miss. In manual mode it is obvious: the user types 0.29, the tool shows 0.28 and reports that the lot was adjusted to the step. In a panel that trades, the order is sent with 0.28 lots, and a 50% partial close of 0.58 closes one step less than half.

The fix: add a small epsilon before flooring. 1e-8 is far larger than the error of a double division (around 1e-14) and far smaller than one step. 0.29 stays 0.29, and a real fraction such as 0.289 still floors to 0.28, so the budget is never exceeded.

6. What the formula still leaves out

The "actual risk" above is the result of closing at the stop price. Two costs are not included:

  • Spread. A buy opens at Ask and closes at Bid, so the spread is lost immediately.
  • Commission. Charged per lot, usually on both sides.

The real loss is roughly formula risk + spread + commission. With high commissions or tight stops this is not negligible. A simple adjustment is to add the current spread to the stop distance, or to subtract the expected commission from the budget.

Exotic symbols can also report a tick value and quotes of zero before their market opens. A calculator should then show "not available" and retry later, not a huge number produced by a division by zero.

OrderCalcProfit has its own weak moment. After switching the terminal language I restarted MetaTrader 5 and immediately ran the script on HK50. OrderCalcProfit() returned success with a result of 0.00. The script fell back to the tick value, and the lot changed from 260 to 33. Thirty seconds later the same script gave the correct result. HK50 is priced in Hong Kong dollars, so my guess is that the HKD/USD conversion quotes were not loaded yet. The lessons: treat a zero result as "not ready yet", report it and calculate again later; and do not compute the point value once in OnInit() and cache it forever, refresh it on a timer.

Here is the Experts log, first run right after the restart (an earlier build that did not yet print the "returned 0" warning):

15:42:25  HK50  point value per lot=0.10000 USD  min lot=1.00  step=1.00  SL=300 points
15:42:25  RISK_PCT_BALANCE   budget=  998.22  raw lot=33.2741  lot=33.00  actual risk=  990.00 (0.99%)  OrderCalcProfit=-0.00  (floored to step)

And the same script 30 seconds later:

15:42:54  HK50: point value from OrderCalcProfit 0.01275 differs from broker tick_value 0.10000 - using 0.01275
15:42:54  HK50  point value per lot=0.01275 USD  min lot=1.00  step=1.00  SL=300 points
15:42:54  RISK_PCT_BALANCE   budget=  998.22  raw lot=260.9732  lot=260.00  actual risk=  994.50 (1.00%)  OrderCalcProfit=-994.32  (floored to step)

7. The complete script and test results

RiskLotCalc.mq5 (full source below) runs on any chart. For the current symbol it prints the budget, raw lot, final lot and actual risk for all nine modes, and cross-checks each final lot with OrderCalcProfit() at the stop price. When the broker's tick value disagrees, it prints an extra line. It never trades.

Inputs:

Input Default Meaning
InpSlPoints 300 stop loss distance in points
InpRiskPct 1.0 risk percent for the % modes
InpRiskMoney 100 budget for the fixed money mode
InpCustomBase 10000 base for the custom % mode
InpManualLot 0.29 lot for manual mode (0.29 shows the rounding trap)

Results from a MetaQuotes-Demo demo account on 26 October 2026, balance $99,822.23, default inputs.

XAUUSD. The first line reports the point value disagreement. All three historical bases come out as $100,000. The manual lot of 0.29 stays 0.29. Every line matches OrderCalcProfit() to the cent.

XAUUSD: point value from OrderCalcProfit 1.00000 differs from broker tick_value 0.10000 - using 1.00000
XAUUSD  point value per lot=1.00000 USD  min lot=0.01  step=0.01  SL=300 points
RISK_FIXED_MONEY     budget=   100.00  raw lot=0.3333  lot=0.33  actual risk=   99.00 (0.10%)  OrderCalcProfit=-99.00  (floored to step)
RISK_PCT_BALANCE     budget=   998.22  raw lot=3.3274  lot=3.32  actual risk=  996.00 (1.00%)  OrderCalcProfit=-996.00  (floored to step)
RISK_PCT_EQUITY      budget=   998.22  raw lot=3.3274  lot=3.32  actual risk=  996.00 (1.00%)  OrderCalcProfit=-996.00  (floored to step)
RISK_PCT_FREE_MARGIN budget=   998.22  raw lot=3.3274  lot=3.32  actual risk=  996.00 (1.00%)  OrderCalcProfit=-996.00  (floored to step)
RISK_PCT_CUSTOM      budget=   100.00  raw lot=0.3333  lot=0.33  actual risk=   99.00 (0.10%)  OrderCalcProfit=-99.00  (floored to step)
RISK_PCT_PREV_DAY    budget=  1000.00  raw lot=3.3333  lot=3.33  actual risk=  999.00 (1.00%)  OrderCalcProfit=-999.00  (floored to step)
RISK_PCT_PREV_WEEK   budget=  1000.00  raw lot=3.3333  lot=3.33  actual risk=  999.00 (1.00%)  OrderCalcProfit=-999.00  (floored to step)
RISK_PCT_PREV_MONTH  budget=  1000.00  raw lot=3.3333  lot=3.33  actual risk=  999.00 (1.00%)  OrderCalcProfit=-999.00  (floored to step)
RISK_MANUAL_LOT      budget=     0.00  raw lot=0.2900  lot=0.29  actual risk=   87.00 (0.09%)  OrderCalcProfit=-87.00

HK50. Point value 0.01275 USD, with the disagreement warning. The minimum lot is 1.00, so the manual 0.29 is raised to 1.00 with WARNING: raised to min lot, actual risk is above budget , which is rule 2 from section 4. Formula risk 99.45 against OrderCalcProfit() 99.43: the two cents come from converting HKD at two different prices.

HK50: point value from OrderCalcProfit 0.01275 differs from broker tick_value 0.10000 - using 0.01275
HK50  point value per lot=0.01275 USD  min lot=1.00  step=1.00  SL=300 points
RISK_FIXED_MONEY     budget=   100.00  raw lot=26.1438   lot=26.00   actual risk=   99.45 (0.10%)  OrderCalcProfit=-99.43   (floored to step)
RISK_PCT_BALANCE     budget=   998.22  raw lot=260.9732  lot=260.00  actual risk=  994.50 (1.00%)  OrderCalcProfit=-994.32  (floored to step)
RISK_PCT_EQUITY      budget=   998.22  raw lot=260.9732  lot=260.00  actual risk=  994.50 (1.00%)  OrderCalcProfit=-994.32  (floored to step)
RISK_PCT_FREE_MARGIN budget=   998.22  raw lot=260.9732  lot=260.00  actual risk=  994.50 (1.00%)  OrderCalcProfit=-994.32  (floored to step)
RISK_PCT_CUSTOM      budget=   100.00  raw lot=26.1438   lot=26.00   actual risk=   99.45 (0.10%)  OrderCalcProfit=-99.43   (floored to step)
RISK_PCT_PREV_DAY    budget=  1000.00  raw lot=261.4379  lot=261.00  actual risk=  998.32 (1.00%)  OrderCalcProfit=-998.14  (floored to step)
RISK_PCT_PREV_WEEK   budget=  1000.00  raw lot=261.4379  lot=261.00  actual risk=  998.32 (1.00%)  OrderCalcProfit=-998.14  (floored to step)
RISK_PCT_PREV_MONTH  budget=  1000.00  raw lot=261.4379  lot=261.00  actual risk=  998.32 (1.00%)  OrderCalcProfit=-998.14  (floored to step)
RISK_MANUAL_LOT      budget=     0.00  raw lot=0.2900    lot=1.00    actual risk=    3.82 (0.00%)  OrderCalcProfit=-3.82    WARNING: raised to min lot, actual risk is above budget

EURUSD. A normal specification: no warning, and every line matches OrderCalcProfit() exactly.

EURUSD  point value per lot=1.00000 USD  min lot=0.01  step=0.01  SL=300 points
RISK_FIXED_MONEY     budget=   100.00  raw lot=0.3333  lot=0.33  actual risk=   99.00 (0.10%)  OrderCalcProfit=-99.00  (floored to step)
RISK_PCT_BALANCE     budget=   998.22  raw lot=3.3274  lot=3.32  actual risk=  996.00 (1.00%)  OrderCalcProfit=-996.00  (floored to step)
RISK_PCT_EQUITY      budget=   998.22  raw lot=3.3274  lot=3.32  actual risk=  996.00 (1.00%)  OrderCalcProfit=-996.00  (floored to step)
RISK_PCT_FREE_MARGIN budget=   998.22  raw lot=3.3274  lot=3.32  actual risk=  996.00 (1.00%)  OrderCalcProfit=-996.00  (floored to step)
RISK_PCT_CUSTOM      budget=   100.00  raw lot=0.3333  lot=0.33  actual risk=   99.00 (0.10%)  OrderCalcProfit=-99.00  (floored to step)
RISK_PCT_PREV_DAY    budget=  1000.00  raw lot=3.3333  lot=3.33  actual risk=  999.00 (1.00%)  OrderCalcProfit=-999.00  (floored to step)
RISK_PCT_PREV_WEEK   budget=  1000.00  raw lot=3.3333  lot=3.33  actual risk=  999.00 (1.00%)  OrderCalcProfit=-999.00  (floored to step)
RISK_PCT_PREV_MONTH  budget=  1000.00  raw lot=3.3333  lot=3.33  actual risk=  999.00 (1.00%)  OrderCalcProfit=-999.00  (floored to step)
RISK_MANUAL_LOT      budget=     0.00  raw lot=0.2900  lot=0.29  actual risk=   87.00 (0.09%)  OrderCalcProfit=-87.00

Conclusion

Position sizing is one line of arithmetic. Reliability is in the details:

  1. Get the point value from OrderCalcProfit() , not from SYMBOL_TRADE_TICK_VALUE alone, and log any disagreement.
  2. Treat a zero result as "not ready", and refresh the cached value.
  3. Decide which "1%" you mean, and remember that historical bases use server time.
  4. Always round down, with an epsilon.
  5. When the minimum lot overrides the budget, say so, and always display the actual risk of the final lot.

Lot size controls how much a single trade can lose. Next time I will cover the other half of risk control: stopping trading when the daily loss limit is reached.

If the script shows a disagreement for a symbol on your broker, post the symbol and the two values in the comments.

Full source: RiskLotCalc.mq5

Save it as MQL5/Scripts/RiskLotCalc.mq5 , compile it in MetaEditor and drop it on any chart. It prints only and never trades.

//+------------------------------------------------------------------+
//| RiskLotCalc.mq5
//| https://edgehone.com/                                             |
//| Lot size from risk: 9 risk bases, step rounding / min-lot clamp, |
//| cross-check with OrderCalcProfit. Prints only, never trades.     |
//+------------------------------------------------------------------+
#property script_show_inputs
#property version "1.0"

enum ENUM_RISK_BASE
{
   RISK_FIXED_MONEY,     // Fixed money
   RISK_PCT_BALANCE,     // % of balance
   RISK_PCT_EQUITY,      // % of equity
   RISK_PCT_FREE_MARGIN, // % of free margin
   RISK_PCT_CUSTOM,      // % of custom base
   RISK_PCT_PREV_DAY,    // % of balance at day start
   RISK_PCT_PREV_WEEK,   // % of balance at week start
   RISK_PCT_PREV_MONTH,  // % of balance at month start
   RISK_MANUAL_LOT       // Manual lot (risk in reverse)
};

input double InpSlPoints = 300;     // Stop loss distance (points)
input double InpRiskPct = 1.0;      // Risk percent
input double InpRiskMoney = 100;    // Risk money (fixed money mode)
input double InpCustomBase = 10000; // Custom base
input double InpManualLot = 0.29;   // Manual lot (0.29 shows the floating-point trap)

//--- Point value from the broker's symbol spec: tick_value * point / tick_size (fallback only)
double PointValueFromTickValue(const string sym)
{
   const double tick_value = SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_VALUE);
   const double tick_size = SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_SIZE);
   const double point = SymbolInfoDouble(sym, SYMBOL_POINT);
   if (tick_value <= 0.0 || tick_size <= 0.0 || point <= 0.0)
      return 0.0; // some exotic symbols report tick_value=0 before the market opens
   return tick_value * point / tick_size;
}

//--- Account-currency value of 1 point for 1.0 lot.
//    Primary: let the terminal compute the loss of 1 lot over 1000 points, then divide by 1000.
//    On MetaQuotes-Demo XAUUSD reports tick_value=0.1 (contract 100 implies 1.0),
//    so trusting tick_value alone gives a lot size 10x too large.
double PointValuePerLot(const string sym)
{
   const double fallback = PointValueFromTickValue(sym);
   const double point = SymbolInfoDouble(sym, SYMBOL_POINT);
   const double price = SymbolInfoDouble(sym, SYMBOL_BID); // last known quote when the market is closed
   const double probe = 1000.0;

   double loss = 0.0;
   if (point <= 0.0 || price <= probe * point ||
       !OrderCalcProfit(ORDER_TYPE_BUY, sym, 1.0, price, price - probe * point, loss))
      return fallback;
   // Right after a terminal restart OrderCalcProfit can succeed but return 0 for
   // cross-currency symbols (conversion rate not loaded yet). Say so instead of
   // silently falling back to a tick_value that may be wrong.
   if (loss >= 0.0)
   {
      PrintFormat("%s: OrderCalcProfit returned %.2f (conversion rate not ready?) - falling back to tick_value, run again shortly",
                  sym, loss);
      return fallback;
   }

   const double pv = -loss / probe;
   if (fallback > 0.0 && MathAbs(pv - fallback) / pv > 0.05)
      PrintFormat("%s: point value from OrderCalcProfit %.5f differs from broker tick_value %.5f - using %.5f",
                  sym, pv, fallback, pv);
   return pv;
}

//--- Decimal places of the volume step (0.01 -> 2, 0.1 -> 1, 1 -> 0)
int VolumeDigits(const double step)
{
   int digits = 0;
   double s = step;
   while (digits < 8 && MathAbs(s - MathRound(s)) > 1e-8)
   {
      s *= 10.0;
      digits++;
   }
   return digits;
}

//--- Floor to the volume step (never exceed the budget). May return less than
//    the broker minimum; the caller decides what to do with that.
double FloorToStep(const string sym, const double lot)
{
   const double step = SymbolInfoDouble(sym, SYMBOL_VOLUME_STEP);
   const double vmax = SymbolInfoDouble(sym, SYMBOL_VOLUME_MAX);
   if (step <= 0.0 || lot <= 0.0)
      return 0.0;
   // 0.29 / 0.01 is 28.999999999999996 in double; without the epsilon it floors to 0.28
   double res = MathFloor(lot / step + 1e-8) * step;
   if (res > vmax)
      res = MathFloor(vmax / step + 1e-8) * step;
   return NormalizeDouble(res, VolumeDigits(step));
}

//--- Balance at a past moment = current balance - every deal after that moment
//    (deposits/withdrawals are DEAL_TYPE_BALANCE deals and are rolled back too)
double BalanceAsOf(const datetime since)
{
   const double bal = AccountInfoDouble(ACCOUNT_BALANCE);
   if (since <= 0 || !HistorySelect(since, TimeCurrent()))
      return bal;
   double delta = 0.0;
   const int n = HistoryDealsTotal();
   for (int i = 0; i < n; i++)
   {
      const ulong ticket = HistoryDealGetTicket(i);
      if (ticket == 0)
         continue;
      delta += HistoryDealGetDouble(ticket, DEAL_PROFIT) + HistoryDealGetDouble(ticket, DEAL_SWAP) + HistoryDealGetDouble(ticket, DEAL_COMMISSION);
   }
   return bal - delta;
}

//--- Period starts: note these are 00:00 in SERVER time
datetime DayStart()
{
   MqlDateTime t;
   TimeToStruct(TimeCurrent(), t);
   t.hour = 0;
   t.min = 0;
   t.sec = 0;
   return StructToTime(t);
}

datetime WeekStart()
{
   const datetime day = DayStart();
   MqlDateTime t;
   TimeToStruct(day, t);
   int dow = t.day_of_week; // 0 = Sunday
   if (dow == 0)
      dow = 7; // week starts on Monday
   return day - (datetime)((dow - 1) * 86400);
}

datetime MonthStart()
{
   MqlDateTime t;
   TimeToStruct(TimeCurrent(), t);
   t.day = 1;
   t.hour = 0;
   t.min = 0;
   t.sec = 0;
   return StructToTime(t);
}

//--- Risk budget in account currency
double RiskBudget(const ENUM_RISK_BASE mode)
{
   switch (mode)
   {
   case RISK_FIXED_MONEY:
      return InpRiskMoney;
   case RISK_PCT_BALANCE:
      return AccountInfoDouble(ACCOUNT_BALANCE) * InpRiskPct / 100.0;
   case RISK_PCT_EQUITY:
      return AccountInfoDouble(ACCOUNT_EQUITY) * InpRiskPct / 100.0;
   case RISK_PCT_FREE_MARGIN:
      return AccountInfoDouble(ACCOUNT_MARGIN_FREE) * InpRiskPct / 100.0;
   case RISK_PCT_CUSTOM:
      return InpCustomBase * InpRiskPct / 100.0;
   case RISK_PCT_PREV_DAY:
      return BalanceAsOf(DayStart()) * InpRiskPct / 100.0;
   case RISK_PCT_PREV_WEEK:
      return BalanceAsOf(WeekStart()) * InpRiskPct / 100.0;
   case RISK_PCT_PREV_MONTH:
      return BalanceAsOf(MonthStart()) * InpRiskPct / 100.0;
   default:
      return 0.0;
   }
}

void OnStart()
{
   const string sym = _Symbol;
   const double pv = PointValuePerLot(sym);
   if (pv <= 0.0)
   {
      Print("Symbol spec not available (tick_value/tick_size is 0), try again after the market opens: ", sym);
      return;
   }
   if (InpSlPoints <= 0.0)
   {
      Print("Stop loss distance must be > 0");
      return;
   }

   const double vmin = SymbolInfoDouble(sym, SYMBOL_VOLUME_MIN);
   const double point = SymbolInfoDouble(sym, SYMBOL_POINT);
   const double ask = SymbolInfoDouble(sym, SYMBOL_ASK);
   const double bal = AccountInfoDouble(ACCOUNT_BALANCE);

   PrintFormat("%s  point value per lot=%.5f %s  min lot=%.2f  step=%.2f  SL=%.0f points",
               sym, pv, AccountInfoString(ACCOUNT_CURRENCY), vmin,
               SymbolInfoDouble(sym, SYMBOL_VOLUME_STEP), InpSlPoints);

   for (int m = RISK_FIXED_MONEY; m <= RISK_MANUAL_LOT; m++)
   {
      const ENUM_RISK_BASE mode = (ENUM_RISK_BASE)m;
      double budget = 0.0, raw = 0.0, lot = 0.0;
      string note = "";

      if (mode == RISK_MANUAL_LOT)
      {
         raw = InpManualLot;
         lot = FloorToStep(sym, raw);
      }
      else
      {
         budget = RiskBudget(mode);
         if (budget <= 0.0)
         {
            PrintFormat("%-22s invalid risk budget", EnumToString(mode));
            continue;
         }
         raw = budget / (InpSlPoints * pv);
         lot = FloorToStep(sym, raw);
      }

      if (lot < vmin)
      {
         lot = vmin;
         note = "  WARNING: raised to min lot, actual risk is above budget";
      }
      else if (lot < raw - 1e-9)
         note = "  (floored to step)";

      // Show the ACTUAL risk of the final lot, not the budget
      const double risk = lot * InpSlPoints * pv;

      // Cross-check with the terminal's own OrderCalcProfit: loss if closed at the SL price
      double check = 0.0;
      const bool ok = (ask > 0.0 &&
                       OrderCalcProfit(ORDER_TYPE_BUY, sym, lot, ask, ask - InpSlPoints * point, check));

      PrintFormat("%-22s budget=%9.2f  raw lot=%.4f  lot=%.2f  actual risk=%8.2f (%.2f%%)  OrderCalcProfit=%s%s",
                  EnumToString(mode), budget, raw, lot, risk,
                  (bal > 0.0 ? risk / bal * 100.0 : 0.0),
                  (ok ? DoubleToString(check, 2) : "n/a"), note);
   }
}
//+------------------------------------------------------------------+