MQL5 仓位计算:九种风险基数,以及为什么别信 SYMBOL_TRADE_TICK_VALUE

MQL5 仓位计算:九种风险基数,以及为什么别信 SYMBOL_TRADE_TICK_VALUE

3 十一月 2026, 02:26
Yang Yu Qun
0
7

引言

“每笔风险 1%”是最常见的风控规则。规则本身很简单,难的是把它换算成手数:

  • 每个品种、每家经纪商的点值都不一样。“1 个 pip = 10 美元”这种经验值,一离开主流外汇对就不灵了。
  • 连经纪商自己给的 SYMBOL_TRADE_TICK_VALUE 都可能是错的。因为它,我的一个工具算出的黄金手数大了 10 倍。
  • 手数要按交易量步长取整。取整方向错了,每一单都会比你设定的多担一点风险。
  • 预算低于最小手数时,计算器照样显示“0.01”,却不告诉你这一单的风险已经超出预算。
  • 还有,“1%”是谁的 1%?余额、净值、可用保证金,还是当天开始时的余额?做 prop 挑战时,这几个答案各不相同。

这篇文章一步步用 MQL5 写一个按风险反推手数的计算器:一个公式、一个靠得住的点值、九种风险基数、取整和兜底规则,再加上和终端结果的自检。文中有两个问题是我自己工具里真实出过的 bug,我会给出出错的代码、修法和测试结果。

全部代码都在脚本 RiskLotCalc.mq5 里,文末附完整源码。它只计算并打印到“专家”日志,从不开仓或改单。

1. 一个公式适用所有品种

仓位计算归根结底就一行:

手数 = 风险预算 / (止损点数 × 1 手每点价值)

难点在最后一项点值:1 手、价格每动 1 个点,按账户货币算值多少钱。别自己用合约大小和汇率去推,终端有两种方式直接给你。

1.1 常见做法:读品种规格

网上几乎所有示例,包括我自己以前的代码,都是这么写的:

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 :价格变动一个 tick 时 1 手的盈亏,以账户货币计。
  • SYMBOL_TRADE_TICK_SIZE :最小价格变动。
  • SYMBOL_POINT :报价最后一位的一个单位。

为什么要乘 point / tick_size ?大多数外汇对和黄金上 tick_size == point ,所以直接把 tick_value 当点值碰巧没问题。但在一些指数上不成立。下面是 2026 年 10 月 26 日在 MetaQuotes-Demo 上读到的三组真实规格:

品种 Point Tick size Tick value 合约大小 盈利货币
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 报价精确到 0.1,但价格每次按 0.5 跳动。这里如果把 tick_value 直接当点值,会高估 5 倍。

另外两个加粗的数字,是下一节要讲的。

1.2 规格本身也会错

上面这个函数在我自己的两个工具(一个手数计算器、一个交易面板)里跑了好几个月。后来我在 MetaQuotes-Demo 上测 XAUUSD:合约大小 100 盎司,tick size 0.01,所以 1 手每个 tick 应该值 1.00 美元。服务器给的 tick value 却是 0.1。


图 1. MetaQuotes-Demo 上的 XAUUSD 规格:合约大小 100,Tick size 0.01,Tick value 0.1

后果很好算。风险 100 美元、止损 300 点:

用 tick_value 算的点值 = 0.1  →  手数 = 100 / (300 × 0.1) = 3.33
真实点值            = 1.0  →  手数 = 100 / (300 × 1.0) = 0.33

3.33 手打到止损,亏损约 1,000 美元。在 10,000 美元的账户上,你以为风险是 1%,实际是 10%。那个交易面板是会下单的,它真的会按这个手数把单子发出去。

这台服务器上的实际盈亏是按合约大小结算的。我之前在同一台服务器上按合约大小 100 回放过历史成交,每一笔都对得上。所以错的是 tick value,不是合约大小。

XAUUSD 不是个例。同一台服务器上,HK50 合约大小为 1,盈利货币是港币。用 tick value 算出的点值是 0.1,而终端的盈亏计算给出的是 0.01275 美元。相差约 7.8 倍,正好是港币兑美元的汇率。看起来 tick value 是按港币给的,没有换算成账户货币。这次算出的手数偏小,属于安全方向,但仍然是错的。

1.3 更好的做法:直接问终端怎么结算盈亏

OrderCalcProfit() 用的就是 MetaTrader 5 结算真实成交的那套计算,品种的计算模式和到账户货币的换算它都会处理。所以直接问它:1 手跌 1000 点亏多少,再除以 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;
}

几个设计取舍:

  • OrderCalcProfit 是主数据源,tick value 只做兜底。 只有在没有报价或调用失败时才用兜底值。
  • 不一致就报出来。 两个值相差超过 5%,往日志打一行,用户就能看出经纪商的规格有问题。
  • 用 1000 点去试,不用 1 点。 1 个点的亏损很小,再除回去会放大舍入误差。
  • 算一次缓存起来,但要刷新。 别每个 tick 都调用。切换品种时重算,并用定时器定期刷新(第 6 节会讲为什么只算一次不够)。

例子:余额 10,000 美元,风险 1% = 100 美元,EURUSD 止损 300 点(30 pips),点值 1.00:

手数 = 100 / (300 × 1.00) = 0.333… → 0.33 手 → 实际风险 = 0.33 × 300 × 1.00 = 99 美元

XAUUSD 止损 300 点(价格 3.00 美元)时,真实点值同样是 1.00,所以也是 0.33 手。

2. 1% 是谁的 1%?九种风险基数

公式里的风险预算,决定了你的风控口径。我的工具支持九种定义方式:

模式 风险预算 适用场景
固定金额 你输入的金额 每笔固定最大亏损
余额 % 余额 × % 经典的固定比例法
净值 % 净值 × % 有持仓时更保守:浮亏会缩小基数
可用保证金 % 可用保证金 × % 持仓多时保护保证金
自定义基数 % 你的基数 × % 出金之后,或按 prop 账户初始规模算
当日开始余额 % 今天 00:00 的余额 × % 按当日起始余额计算的 prop 规则
本周开始余额 % 周一 00:00 的余额 × % 周风险上限
本月开始余额 % 1 号 00:00 的余额 × % 月风险上限
手动手数 反算:输入手数,输出风险 检查某个手数实际承担多少风险

余额和净值差多少?余额 10,000 美元,有一笔持仓浮亏 1,500 美元:

  • 余额的 1% → 100 美元
  • 净值的 1% → 85 美元

浮亏越深,按净值算的规则就会自动把新仓位压得越小。

代码就是一个 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;
   }
}

前五种直接读账户属性。后三种需要过去某个时刻的余额,而 MQL5 没有现成的函数。

3. 过去某个时刻的余额:倒推

思路:T 时刻的余额 = 当前余额 − 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;
}

有三个细节:

  1. 盈利、隔夜利息、手续费都要减。 只减盈利,在高手续费账户上会越算越偏。
  2. 入金和出金也会被倒推回去。 它们在历史里是 DEAL_TYPE_BALANCE 类型的成交,金额在 DEAL_PROFIT 里。中午入金 5,000 美元,“当日开始余额”仍是入金前的数,这通常正是 prop 规则的意思。如果只想要交易结果,按 DEAL_TYPE 过滤。
  3. 别每个 tick 都跑。 HistorySelect() 不是免费的。启动时和换日时算一次,缓存起来。

周期起点用的是服务器时间。 TimeCurrent() 返回服务器时间,所以 DayStart() 是服务器的午夜。很多 prop 公司按自己的时区换日,不一定是经纪商的午夜。用“当日开始余额”去对照 prop 规则前,先确认那家公司的一天从几点开始。各家的规则也不一样:有的看余额,有的看余额和净值中较高的那个。

第 7 节测试时账户余额是 99,822.23 美元,三个历史基数都算出 100,000 美元:差额正好是当天早些时候已平仓交易的结果。

4. 取整和兜底:多出来的风险从这里溜进来

公式算出的是原始手数,比如 0.3333。经纪商只接受 SYMBOL_VOLUME_STEP 的整数倍,并且在 SYMBOL_VOLUME_MIN 和 SYMBOL_VOLUME_MAX 之间。有三条规则。

规则 1:永远向下取整。 四舍五入会把 0.335 变成 0.34,每一单都多担风险。风控工具永远不该超预算:

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

( + 1e-8 的来由见第 5 节。)

规则 2:最小手数压过预算时要明说。 小账户经常碰到:

余额 1,000 美元,风险 0.5% = 5 美元,XAUUSD 止损 800 点,点值 1.00
原始手数 = 5 / (800 × 1.00) = 0.00625 → 低于最小手数 0.01

两种选择:拒绝,或者抬到最小手数。我的工具选择抬上去,因为用户通常无论如何都会下单,同时给出警告:这一单实际风险是 0.01 × 800 × 1.00 = 8 美元,占余额 0.8%,是预算的 1.6 倍。

规则 3:显示实际风险,而不是预算。 取整和兜底之后,用最终手数重新算一遍风险:

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

很多计算器在风险栏里显示的是你输入的数(5 美元),而不是这一单实际会亏的钱(8 美元)。差别只有一行代码。

5. 第二个真实 bug:少一档的浮点陷阱

规则 1 里的 + 1e-8 来自我修过的另一个 bug。原来那行是:

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

看着没错,但 0.29 在二进制浮点里没法精确表示:

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

步长 0.01 时,0.01 到 10.00 的 1000 个手数里有 125 个会少一档。按风险算手数时只少 0.01 手,而且是安全方向,所以很容易漏掉。手动模式下就很明显了:用户输入 0.29,工具显示 0.28,还提示手数已按步长调整。在会下单的面板里,单子就按 0.28 手发出去;对 0.58 手做 50% 部分平仓,会比一半少平一档。

修法:向下取整前加一个很小的 epsilon。 1e-8 远大于 double 除法的误差(约 1e-14),又远小于一个步长。0.29 保持 0.29,而真正的小数如 0.289 仍然取整到 0.28,永远不会超预算。

6. 公式还没算进去的东西

上面的“实际风险”是按止损价平仓的结果,有两项成本没算:

  • 点差。 买单按 Ask 开、按 Bid 平,一开仓就先亏一个点差。
  • 手续费。 按手收,通常开平两边都收。

真实亏损约等于 公式风险 + 点差 + 手续费。手续费高或止损很近时,这部分不能忽略。简单的修正是把当前点差加到止损距离上,或者从预算里扣掉预计的手续费。

冷门品种在开盘前还可能报出为零的 tick value 和报价。这时计算器应该显示“暂不可用”并稍后重试,而不是除以零算出一个巨大的数。

OrderCalcProfit 也有它的薄弱时刻。 我切换终端语言后重启了 MetaTrader 5,马上在 HK50 上跑脚本。 OrderCalcProfit() 返回成功,结果却是 0.00。脚本退回用 tick value,手数从 260 变成了 33。30 秒后同一个脚本给出了正确结果。HK50 以港币计价,我猜是港币兑美元的换算报价还没加载好。教训有两条:把结果为零当成“还没准备好”,报出来并稍后重算;也别在 OnInit() 里算一次点值就永久缓存,要用定时器刷新。

下面是“专家”日志,第一次是重启后马上跑的(那时的版本还不会打印“returned 0”警告):

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)

30 秒后同一个脚本:

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. 完整脚本与测试结果

RiskLotCalc.mq5 (完整源码见文末)可以挂在任何图表上。它对当前品种打印九种模式各自的预算、原始手数、最终手数和实际风险,并用 OrderCalcProfit() 按止损价逐一核对最终手数。经纪商的 tick value 对不上时,会多打一行提示。它从不下单。

输入参数:

参数 默认值 含义
InpSlPoints 300 止损距离(点)
InpRiskPct 1.0 % 类模式的风险百分比
InpRiskMoney 100 固定金额模式的预算
InpCustomBase 10000 自定义 % 模式的基数
InpManualLot 0.29 手动模式的手数(0.29 用来演示取整陷阱)

以下结果来自 2026 年 10 月 26 日 MetaQuotes-Demo 模拟账户,余额 99,822.23 美元,默认参数。

XAUUSD。 第一行报告了点值不一致。三个历史基数都是 100,000 美元。手动手数 0.29 保持 0.29。每一行都和 OrderCalcProfit() 精确到分。

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。 点值 0.01275 美元,带不一致警告。最小手数是 1.00,所以手动的 0.29 被抬到 1.00,并提示 WARNING: raised to min lot, actual risk is above budget ,这就是第 4 节的规则 2。公式风险 99.45 对比 OrderCalcProfit() 的 99.43:两分钱的差来自港币按两个不同价格换算。

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。 规格正常:没有警告,每一行都和 OrderCalcProfit() 完全一致。

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

结论

仓位计算就是一行算术,靠不靠得住全在细节:

  1. 点值从 OrderCalcProfit() 取,别只靠 SYMBOL_TRADE_TICK_VALUE ,不一致就记日志。
  2. 结果为零当成“还没准备好”,缓存的值要定期刷新。
  3. 想清楚你说的“1%”是哪个,并记住历史基数用的是服务器时间。
  4. 永远向下取整,带上 epsilon。
  5. 最小手数压过预算时要明说,并始终显示最终手数的实际风险。

手数决定单笔交易最多亏多少。下次我会讲风控的另一半:达到当日亏损上限就停止交易。

如果脚本在你的经纪商某个品种上报出了不一致,欢迎在评论里贴出品种名和那两个数值。

完整源码:RiskLotCalc.mq5

保存为 MQL5/Scripts/RiskLotCalc.mq5 ,在 MetaEditor 里编译,然后拖到任意图表上。它只打印,从不下单。

//+------------------------------------------------------------------+
//| 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);
   }
}
//+------------------------------------------------------------------+