Position size calculator API: lots from the symbol spec

11 min readMetaKit

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:

  1. 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.
  2. 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 to volume_min.
  3. If the result is above volume_max, cap it. Your actual risk is now lower than planned, which is the acceptable direction.
  4. Do the floor in integer steps and round the result to a sane number of decimals, so 0.5300000000000001 never 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.