You want to risk 1% of a $10,000 account on a EURUSD trade with a 25-pip stop. The answer is 0.40 lots. Most people get there by plugging numbers into a website, and then have no idea what to do when the instrument is gold, the account is in euros, or the broker's minimum lot is bigger than the answer.
The lot size calculator formula is three lines of arithmetic. The hard part is the inputs, because every one of them comes from the broker.
A lot is a contract size, not a number
One standard lot is 100,000 units of the base currency. One lot of EURUSD is 100,000 euros. A mini lot is 0.10 (10,000 units), a micro lot is 0.01 (1,000 units), and MT5 expresses all of them as decimal fractions of a standard lot.
That 100,000 is not a law of nature. It's the symbol's contract_size, and
it's 100,000 on forex almost everywhere, but 100 troy ounces on most gold
symbols, 5,000 on silver, and often 1 on indices and share CFDs. The
symbol suffix post
covers how much this varies between brokers. For sizing, the point is simpler:
never write 100000 in your code. Read it.
Pip vs point
A point is the smallest price increment the broker quotes: 10 ** -digits.
A pip is the traditional unit traders talk in: 0.0001 on most pairs, 0.01 on
yen pairs.
On a five-digit broker, EURUSD is quoted as 1.08453, one point is 0.00001,
and one pip is ten points. On a four-digit broker they're the same thing. This
is why stop_pips * 0.0001 is wrong on USDJPY (a pip is 0.01 there) and
stop_points * point is right everywhere, provided you compute the point from
digits.
Gold is where the word "pip" falls apart. On a two-digit gold quote (2031.45)
some people call 0.01 a pip, others call 0.10 a pip, and a few mean a full
dollar. Don't use the word. Use tick_size, which the broker defines and
which never argues back.
Pip value, three ways
Pip value is money per pip per lot, in the account currency. The general
formula is contract_size × pip_size, which gives a value in the quote
currency, then converted to the account currency if they differ.
Quote-USD pair on a USD account (EURUSD). 100,000 × 0.0001 = $10 per pip per lot. No conversion. This is the number everyone memorises, and it's only true for pairs that end in the account currency.
USD-base pair (USDJPY at 150.00). 100,000 × 0.01 = 1,000 JPY per pip per lot. Convert to USD by dividing by the rate: 1,000 / 150.00 = $6.67. Move the rate to 140.00 and it becomes $7.14. Pip value on these pairs changes with price, which is why yesterday's number is wrong today.
Cross pair (EURGBP on a USD account, GBPUSD at 1.2700). 100,000 × 0.0001 = 10 GBP per pip per lot. Convert through GBPUSD: 10 × 1.2700 = $12.70. You need a second quote to size the first trade, and if the account is in EUR you need a different second quote.
Gold (XAUUSD, 100 oz contract, USD account). One point of 0.01 is 100 × 0.01 = $1 per lot. A one-dollar move is $100 per lot. On a three-digit gold feed the point is $0.10, but a one-dollar move is still $100, because contract size didn't change. Size gold in dollars of price movement, not pips, and the digits stop mattering.
There is a shortcut that handles all four cases without you doing the currency
conversion: MT5 publishes tick_value for every symbol, the money one
tick_size move is worth per lot, already in the account currency. Pip value
is then tick_value × (pip_size / tick_size). The broker did the cross-rate
lookup for you. Read it fresh, since it moves with price on non-USD pairs.
Risk-based position sizing, with numbers
The formula:
lots = risk_money / (stop_distance_in_pips × pip_value_per_lot)
Example 1. $10,000 account, 1% risk, EURUSD, 25-pip stop. Risk money is $100. Pip value is $10. Lots = 100 / (25 × 10) = 0.40. Check it: 0.40 lots × 25 pips × $10 = $100. Good.
Example 2. $5,000 account, 2% risk, USDJPY at 150.00, 40-pip stop. Risk
money is $100. Pip value is $6.67. Lots = 100 / (40 × 6.67) = 0.3748. The
broker's lot_step is 0.01, so round down to 0.37. Actual risk is 0.37
× 40 × 6.67 = $98.72. Rounding up to 0.38 would risk $101.38, which is over
budget, and "over budget by a dollar" becomes "over budget by 8%" once the
account is smaller and the step is coarser.
Example 3. $20,000 account, 0.5% risk, XAUUSD, stop $8 away. Risk money is $100. At $1 per 0.01 point per lot, an $8 stop is 800 points, so risk per lot is $800. Lots = 100 / 800 = 0.125. Round down to the 0.01 step: 0.12. Actual risk $96.
And the case the website calculators skip: $300 account, 1% risk, EURUSD,
30-pip stop. Risk money $3. Lots = 3 / (30 × 10) = 0.01. Fine. Make the stop
40 pips and the answer is 0.0075, which is below the broker's min_lot of
0.01. There is no lot size that satisfies the risk rule. Either skip the trade
or knowingly take 1.33% risk. The formula can't decide that for you, but your
code has to notice, rather than silently rounding 0.0075 up to 0.01.
Rounding is part of the formula
Every broker publishes three volume constraints per symbol: min_lot,
max_lot, and lot_step. Orders that don't sit on the grid are rejected
outright with "invalid volume", and the grid is not always 0.01. Index CFDs
with a step of 0.1 or a minimum of 1.0 are common.
Always floor to the step, then clamp. Floor because rounding to nearest can
push you over the risk budget; clamp because a 0.005 result on a 0.01 minimum
means "don't", not "0.01". Do the floor in integer steps to dodge floating
point: floor(raw / step) * step, then round to a sensible number of decimals
so 0.37000000000000005 doesn't reach the broker.
The same maths runs inside a copier
A trade copier is a position sizer that runs on someone else's trade. The three sizing modes in a MetaKit copier map directly onto what you just did by hand:
multiplier: follower lots = master lots ×lot_value. The master already did the risk calculation; you're scaling their answer. Use this when both accounts trade the same instruments on the same contract sizes.fixed: follower lots =lot_value, always. Predictable and mostly wrong, since the master's 0.10 on a 10-pip stop and their 0.10 on a 100-pip stop carry very different risk.proportional(balance-ratio): follower lots = master lots × (follower balance / master balance). A $10k follower copies a $100k master at 0.1×. This is the mode that feels like risk parity, and it's the one that goes wrong in interesting ways.
Balance-ratio breaks when the two accounts aren't comparable. If the master is
in USD and the follower in EUR, the ratio compares raw balance numbers in
different currencies and is off by the exchange rate. If the follower is on
1:30 leverage and the master on 1:500, the scaled lots can be perfectly
proportional and still exceed the follower's free margin, so the order is
rejected on "not enough money" while the master sails on. And if the two
brokers disagree on contract size, proportional lots are proportional amounts
of different things. The ratio is a good starting point and a bad final
answer; filters.max_lot exists to put a ceiling under the surprises.
A function that does it from symbol info
Everything above collapses into one function once the inputs come from the
broker. This pulls the symbol spec through MetaKit's
GET /v1/accounts/{id}/symbols/{symbol} and sizes from tick_size,
tick_value, and the volume constraints. The stop is in price units, not pips,
so the same code works on EURUSD (0.0025 for 25 pips), USDJPY (0.40 for
40 pips), and gold (8.0 for eight dollars).
import math
import os
import requests
BASE = "https://api.metakit.cloud"
HEADERS = {"Authorization": f"Bearer {os.environ['METAKIT_KEY']}"}
def symbol_spec(account_id: int, symbol: str) -> dict:
res = requests.get(
f"{BASE}/v1/accounts/{account_id}/symbols/{symbol}", headers=HEADERS
)
res.raise_for_status()
return res.json()
def lots_for_risk(spec: dict, risk_money: float, stop_distance: float) -> float:
"""Return a broker-valid lot size, or 0.0 if the risk can't be met.
spec: the symbol spec from the API (tick_size, tick_value, min_lot, ...).
risk_money: amount to risk, in the account currency.
stop_distance: stop distance in price units (0.0025 = 25 pips on EURUSD).
"""
ticks_in_stop = stop_distance / spec["tick_size"]
risk_per_lot = ticks_in_stop * spec["tick_value"] # account currency
raw = risk_money / risk_per_lot
step = spec["lot_step"]
lots = math.floor(raw / step + 1e-9) * step
lots = round(lots, 8)
if lots < spec["min_lot"]:
return 0.0
return min(lots, spec["max_lot"])
account = requests.get(f"{BASE}/v1/accounts/2", headers=HEADERS).json()
risk = account["balance"] * 0.01
for symbol, stop in [("EURUSD", 0.0025), ("USDJPY", 0.40), ("XAUUSD", 8.0)]:
spec = symbol_spec(2, symbol)
print(symbol, lots_for_risk(spec, risk, stop))The account has to be connected before the spec endpoint returns anything
(it's read live from the broker, so you get today's tick_value, not a cached
one). Note also that tick_value on a non-USD pair drifts with price during
the day; sizing a trade in the morning with a spec you fetched last night is
the same category of bug as hardcoding $10.
Full field list for the symbol spec and the copier sizing options in the API reference.
If you take one thing from this: the formula is trivial, the constants are not constants. Pip value moves with price, contract size moves with broker, and the lot grid moves with symbol. Fetch all three, every time, and the 0.40 will take care of itself.