Most position sizing code guesses the contract size from the symbol name.
if symbol.startswith("XAU") gets 100, anything with US30 in it gets 1,
everything else gets 100,000. That's fine for a dashboard card that says
"you're risking about $95". It's wrong the moment the same number goes into
an order, because the broker doesn't accept "about". It accepts a volume that
sits exactly on volume_step, between volume_min and volume_max, with a
stop at least stops_level points away, or it rejects the order.
A position size calculator that runs against the API has no reason to guess.
GET /v1/accounts/{id}/symbols/{symbol} returns every number the broker will
validate against. The lot size post
covers the arithmetic and the pip-versus-point confusion; this is the sequel
for when the output is an order rather than a card.
The symbol spec fields that decide the lot size
Fetch gold on a broker that suffixes with .m:
curl -s "https://api.metakit.cloud/v1/accounts/2/symbols/XAUUSD.m" \
-H "Authorization: Bearer $METAKIT_KEY"{
"symbol": "XAUUSD.m",
"digits": 2,
"contract_size": 100,
"volume_min": 0.01,
"volume_max": 50,
"volume_step": 0.01,
"tick_size": 0.01,
"tick_value": 1.0,
"stops_level": 30,
"freeze_level": 0,
"trade_mode": "full",
"currency_base": "XAU",
"currency_profit": "USD",
"currency_margin": "USD",
"bid": 2331.40,
"ask": 2331.65,
"spread": 25
}(The older names, min_lot, lot_step, stop_level and friends, are in
the response too. Same values, both spellings stable.)
What each one is for:
contract_size is how many units one lot controls. 100 ounces here, 100,000
units of base currency on FX, 1 on most index CFDs, and it varies between
brokers more than you'd expect (the
symbol suffix post
has the catalogue). You need it to sanity-check tick_value and for almost
nothing else.
tick_size is the smallest price move the broker will quote. digits is how
many decimals it prints. They usually agree (10 ** -digits) but don't have
to; some index CFDs print two decimals and tick in 0.1. When they disagree,
tick_size wins for money maths and digits wins for formatting a price.
tick_value is what one tick_size move is worth, per lot, in the account
currency. The broker has already converted it from currency_profit. This is
the field that makes the sizing formula one line.
volume_min, volume_max and volume_step are the lot grid. The API checks
all three before touching the terminal, and it never rounds for you.
stops_level is the minimum distance, in points, between the current price
and any stop, target or pending price. freeze_level is a different thing
and gets its own section below.
currency_profit is the currency a P/L on this symbol is realised in before
conversion. Compare it to currency on GET /v1/accounts/{id} and you know
whether tick_value will drift during the day.
trade_mode says whether you can trade the side you want at all: full,
long_only, short_only, close_only or disabled. Sizing a short on a
long_only symbol is a 422 symbol_not_tradeable waiting to happen.
The formula is risk divided by ticks times tick value
stop_ticks = stop_distance / tick_size
risk_per_lot = stop_ticks × tick_value
lots = risk_money / risk_per_lot
stop_distance is in price units: 0.0028 for 28 pips on a five-digit
EURUSD, 6.50 for six and a half dollars on gold, 45 for 45 index points. Not
pips: nobody agrees what a pip is on gold, and the broker publishes ticks.
Everything the classic formula needed you to know (contract size, quote
currency, the cross rate into your account currency) is folded into
tick_value. That's the whole reason to size from the spec rather than from
a table of constants.
Three worked examples with real numbers
EURUSD on a five-digit broker
Spec: digits 5, contract_size 100000, tick_size 0.00001, tick_value
1.0 on a USD account, volume_min 0.01, volume_max 100, volume_step
0.01, stops_level 10.
Risk $150 (1.5% of $10,000), stop 28 pips away. 28 pips is 0.00280, which is 280 ticks. Risk per lot is 280 × $1.00 = $280. Lots = 150 / 280 = 0.5357. Floor to the 0.01 step: 0.53. Actual risk 0.53 × 280 = $148.40.
Cross-check with the contract: 0.53 lots × 100,000 × 0.0028 = $148.40. Same number, because on a USD-quoted pair on a USD account there's nothing to convert.
XAUUSD.m with a contract size of 100
Spec: the JSON above. Risk $200, stop $6.50 below entry. That's 650 ticks of 0.01. Risk per lot is 650 × $1.00 = $650. Lots = 200 / 650 = 0.3077. Floor to 0.01: 0.30. Actual risk $195.
Cross-check: 0.30 lots × 100 oz × $6.50 = $195.
An index CFD with a contract size of 1
Spec for a US30 CFD: digits 1, contract_size 1, tick_size 0.1,
tick_value 0.1, volume_min 0.1, volume_max 50, volume_step 0.1,
stops_level 30.
Risk $120, stop 45 index points away. That's 450 ticks. Risk per lot is 450 × $0.10 = $45. Lots = 120 / 45 = 2.6667. Floor to the 0.1 step: 2.6. Actual risk $117.
Two things to notice. Nearest-rounding gives 2.7, which risks $121.50 and is
over budget. And code that assumes a 0.01 step sends 2.66, which is off-grid
and comes back as 422 invalid_volume without the broker ever seeing it.
Both bugs are invisible on EURUSD and show up the first time someone trades
an index.
Round down to volume_step, then clamp
The rule, in order:
- Floor to
volume_step. Floor, not round: rounding up spends more than the risk budget, and on a coarse grid it can be a lot more. - If the result is below
volume_min, there is no valid size that meets your risk rule. Return nothing and let the caller decide whether to skip or knowingly over-risk. Do not bump tovolume_min. - If the result is above
volume_max, cap it. Your actual risk is now lower than planned, which is the acceptable direction. - Do the floor in integer steps and round the result to a sane number of
decimals, so
0.5300000000000001never leaves your process.
The API deliberately rejects rather than rounds. Send 0.5357 and the guard
returns 422 invalid_volume before the terminal is touched; the broker's own
equivalents (retcodes 10014 and 10038) map to the same code if one gets past
the guard. We could have rounded on your behalf and chose not to, because
there are two directions to round in and only you know which one your risk
rule allows. A rejected order is a visible bug you fix once. A silently
rounded one is an invisible bug that adds a few percent of risk to every
trade forever.
Check stops_level before you send
stops_level is in points, so convert it: min_distance = stops_level × 10 ** -digits.
For the gold spec above that's 30 × 0.01 = $0.30. For the US30 spec it's
30 × 0.1 = 3 index points.
The distance is measured from the price the position would close at, not the
price it opens at. A long opens at the ask and closes at the bid, so its stop
loss has to be at least min_distance below the bid and its take profit at
least that far above the bid. A short is the mirror, measured from the ask. In
practice the spread eats into your SL room on a long: a 30-point stop on a
symbol with a 25-point spread and a 30-point stops_level is a
422 invalid_stops.
Pending orders get the same treatment plus a side rule. A limit buys below
the market and sells above; a stop buys above and sells below; the price has
to be on that side and at least stops_level away. The API checks all of this
before sending, and the broker's own 10016 rejection maps to the same
invalid_stops code if it disagrees.
A stops_level of 0 means no published minimum, not that anything goes; a
last-moment broker rejection comes back as invalid_stops too.
freeze_level is the other distance, and it applies to what you already
have. When the market is within freeze_level points of a pending order's
price, or of an open position's stop or target, MT5 won't let that order be
cancelled or that stop be moved. It's zero on the gold spec above and on most
FX, and non-zero on some indices and around news. It doesn't affect placing a
new order, so the sizing function ignores it; check it before a PATCH on a
position that's about to hit its stop, or the modify fails.
The currency trap: currency_profit vs the account currency
tick_value is already in the account currency. The trap is doing the maths
yourself instead. contract_size × tick_size gives you the value of one tick
in currency_profit, and on EURGBP that's pounds. 100,000 × 0.00001 = 0.1 GBP
per tick per lot, and if the account is USD the broker's tick_value will
read something like 0.127. Size from 0.1 and you're a quarter under on every
EURGBP trade, over on some other cross, and never wrong on EURUSD, which is
why the bug survives code review.
The second half of the trap is time. On symbols where currency_profit
matches the account currency (EURUSD on a USD account, XAUUSD.m on a USD
account) tick_value is constant. On any other symbol it's a snapshot of the
cross rate at the moment you fetched it. USDJPY on a USD account has
currency_profit JPY, and its tick_value changes every time USDJPY does.
Fetch the spec when you size, not at startup. If currency_profit differs
from the account's currency, treat the result as good for minutes, not
hours.
The spec endpoint reads live from the terminal, so the account has to be
connected or you get a 409 account_not_running or a 502. And the bid
and ask on the spec are from a snapshot that can lag; use /quote for the
entry price.
A Python function that does all of it
Spec plus quote in, an order body out, or None when there is no valid size
or the stop is inside stops_level.
import math
import os
import uuid
import requests
BASE = "https://api.metakit.cloud"
HEADERS = {"Authorization": f"Bearer {os.environ['METAKIT_KEY']}"}
def get(path: str, **params) -> dict:
res = requests.get(f"{BASE}{path}", headers=HEADERS, params=params, timeout=10)
res.raise_for_status()
return res.json()
def size_order(
account_id: int,
symbol: str,
side: str, # "buy" or "sell"
risk_money: float, # in the account currency
stop_distance: float, # in price units: 0.0028, 6.50, 45
reward_ratio: float = 2.0,
) -> dict | None:
"""Return a POST /orders body sized to risk_money, or None if no valid order exists."""
account = get(f"/v1/accounts/{account_id}")
spec = get(f"/v1/accounts/{account_id}/symbols/{symbol}")
quote = get(f"/v1/accounts/{account_id}/quote", symbol=symbol)
allowed = {"buy": ("full", "long_only"), "sell": ("full", "short_only")}[side]
if spec["trade_mode"] not in allowed:
return None # would be 422 symbol_not_tradeable
# 1. Lots from risk. tick_value is already in the account currency.
stop_ticks = stop_distance / spec["tick_size"]
risk_per_lot = stop_ticks * spec["tick_value"]
raw = risk_money / risk_per_lot
# 2. Floor to the step in integer steps, then clamp.
step = spec["volume_step"]
lots = round(math.floor(raw / step + 1e-9) * step, 8)
if lots < spec["volume_min"]:
return None # no size meets the risk rule; don't bump up
lots = min(lots, spec["volume_max"])
# 3. Stops, measured from the side the position would close on.
digits = spec["digits"]
min_distance = spec["stops_level"] * 10 ** -digits
if side == "buy":
entry, close_side = quote["ask"], quote["bid"]
sl = entry - stop_distance
tp = entry + stop_distance * reward_ratio
ok = (close_side - sl) >= min_distance and (tp - close_side) >= min_distance
else:
entry, close_side = quote["bid"], quote["ask"]
sl = entry + stop_distance
tp = entry - stop_distance * reward_ratio
ok = (sl - close_side) >= min_distance and (close_side - tp) >= min_distance
if not ok:
return None # would be 422 invalid_stops
if spec["currency_profit"] != account["currency"]:
print(f"{symbol}: tick_value is converted from {spec['currency_profit']}; size right before sending")
return {
"symbol": symbol,
"side": side,
"type": "market",
"volume": lots,
"sl": round(sl, digits),
"tp": round(tp, digits),
"deviation": 20,
"comment": f"risk {risk_money:g} stop {stop_distance:g}"[:31],
}
def place(account_id: int, order: dict) -> dict:
res = requests.post(
f"{BASE}/v1/accounts/{account_id}/orders",
headers={**HEADERS, "Idempotency-Key": f"size-{uuid.uuid4()}"},
json=order,
timeout=15, # the API caps a market order at 10 s
)
body = res.json()
if res.status_code not in (200, 201):
err = body["error"]
raise RuntimeError(f"{res.status_code} {err['code']}: {err['message']}")
return body
if __name__ == "__main__":
order = size_order(2, "XAUUSD.m", "buy", risk_money=200, stop_distance=6.50)
if order is None:
print("no valid order for that risk; skipping")
else:
print(order["volume"], order["sl"], order["tp"]) # 0.3 2325.15 2344.65
result = place(2, order)
print(result["status"], result["fill_price"], result["retcode_message"])The returned body goes straight to POST /v1/accounts/{id}/orders with an
Idempotency-Key, which the place orders post
walks through, including what to do when the answer is a
504 terminal_timeout. If the broker still says no, the
retcodes post decodes the
number it sends back.
The full field list for the spec, the quote and the order body is in llms.txt; the same reference rendered for humans is at app.metakit.cloud/docs.
The symbol name told you it was gold. The spec tells you it's 100 ounces, ticks in cents, trades in hundredths of a lot and wants 30 points of clearance. Only one of those is enough to send an order.