Sizing a position
The runnable half of this page is
samples/position_sizing.py, which type-checks against the SDK as it shipped.
If you take one thing from this page: do the arithmetic in Decimal, never in float.
Why a float is the wrong tool here
A position size is computed — a risk budget divided by a stop distance — and binary floating point cannot hold the decimals money is written in:
>>> 0.07 * 3
0.21000000000000002
>>> 1.1 + 0.00021
1.1002100000000001
Those are not display quirks. They are the numbers, and 0.21000000000000002 lots is not a size any
broker deals in. MFX takes every decimal on the execution path as a string for that reason, in
both directions, and the SDK refuses a float rather than quietly sending one:
>>> await client.execution.place_order(..., volume=0.07 * 3, ...)
TypeError: volume must be a Decimal, a str or an int, not a float: a computed float carries
binary error (0.07 * 3 is 0.21000000000000002) and the platform takes decimals as strings so
nothing is lost. Send Decimal("0.21000000000000002") or a string.
That error is deliberate. A 400 from the edge, or worse a fill at a size you did not choose, is a
far worse way to learn this.
The rules
- Build every decimal with
Decimal(str).Decimal("0.1")is a tenth;Decimal(0.1)carries the float’s error straight into theDecimal, which defeats the point. - Read the broker’s own rules from the catalogue.
volume_step,volume_minandcontract_sizeall arrive as strings, soDecimal()takes them exactly. - Quantise to
volume_step, rounding down. Rounding up risks more than your budget, which is the one direction a sizing rule must never take on its own. - Check
volume_minbefore you send. A budget too small for one minimum lot is a decision for you, not a rejection to discover at the broker.
raw = risk / (stop_distance * contract_size)
volume = raw.quantize(volume_step, rounding=ROUND_DOWN)
What the platform does with it
The value you send is the value that reaches the terminal. If a decimal does arrive slightly off the symbol’s step — from an internal caller, or from arithmetic done in binary — the agent puts it exactly onto that step before sending, and anything further off than float noise is sent as you wrote it: MFX never quietly moves a price you chose. A volume genuinely off the step is refused, with the reason.
Try it
python samples/position_sizing.py --key-id mfxk_... --private-key-file key.txt \
--terminal-id <uuid> --broker-id <uuid> --symbol EURUSD.sd \
--risk 50 --stop-distance 0.00150
Nothing is placed unless you add --place. Without it the sample prints the size it would send,
which is the half worth reading first.
The runnable sample: samples/position_sizing.py
Executed in CI against MFX Hub on every change to the knowledge base.
"""Size a position from a risk budget, exactly — the runnable half of guides/sizing-a-position.md.
The one thing to take away: **do the arithmetic in `Decimal` and never in `float`.** A size is
computed, not typed — risk budget over stop distance — and in binary floating point `0.07 * 3` is
`0.21000000000000002`. MFX takes decimals on the execution path as strings for that reason,
and the SDK refuses a `float` rather than quietly sending one:
>>> await client.execution.place_order(..., volume=0.07 * 3, ...)
TypeError: volume must be a Decimal, a str or an int, not a float: a computed float carries
binary error (0.07 * 3 is 0.21000000000000002) ...
Run it against dev:
python samples/position_sizing.py --key-id mfxk_... --private-key-file key.txt \
--terminal-id <uuid> --broker-id <uuid> --symbol EURUSD.sd \
--risk 50 --stop-distance 0.00150
Nothing is placed unless you pass --place. Without it the sample prints the size it would send, which
is the half worth reading.
"""
from __future__ import annotations
import argparse
import asyncio
import uuid
from decimal import ROUND_DOWN, Decimal
from pathlib import Path
from mfx import MfxClient, SigningKey
BASE_URL = "https://api.dev.mfxhub.com/v1"
def lots_for(
*, risk: Decimal, stop_distance: Decimal, contract_size: Decimal, volume_step: Decimal
) -> Decimal:
"""How many lots put `risk` of the account currency behind `stop_distance`, on this symbol.
Rounded **down** to the symbol's own step: rounding up would risk more than the budget, which is
the one direction a sizing rule must never take on its own. `quantize` keeps the result exact —
the same sum in `float` is where 0.21 becomes 0.21000000000000002.
"""
if stop_distance <= 0 or contract_size <= 0 or volume_step <= 0:
raise ValueError("stop distance, contract size and volume step must all be above zero")
raw = risk / (stop_distance * contract_size)
return raw.quantize(volume_step, rounding=ROUND_DOWN)
async def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--key-id", required=True)
parser.add_argument("--private-key-file", required=True, type=Path)
parser.add_argument("--terminal-id", required=True)
parser.add_argument("--broker-id", required=True)
parser.add_argument("--symbol", required=True, help="the broker's own name, e.g. EURUSD.sd")
parser.add_argument("--risk", required=True, help="account currency to put at risk, e.g. 50")
parser.add_argument("--stop-distance", required=True, help="price distance to the stop")
parser.add_argument("--side", default="buy", choices=("buy", "sell"))
parser.add_argument("--base-url", default=BASE_URL)
parser.add_argument("--place", action="store_true", help="actually place the order")
args = parser.parse_args()
# Decimal(str) from the start: Decimal(0.1) would carry the float's error into the Decimal.
risk = Decimal(args.risk)
stop_distance = Decimal(args.stop_distance)
key = SigningKey(args.private_key_file.read_text(encoding="utf-8").strip())
async with MfxClient(args.base_url, args.key_id, key) as client:
# The broker's own rules for this symbol. Every decimal it returns is a string, so
# Decimal() takes them exactly.
symbol = await client.catalog.symbol(args.broker_id, args.symbol)
contract_size = Decimal(symbol.contract_size)
volume_step = Decimal(symbol.volume_step)
volume_min = Decimal(symbol.volume_min)
volume = lots_for(
risk=risk,
stop_distance=stop_distance,
contract_size=contract_size,
volume_step=volume_step,
)
print(f"{args.symbol}: step {volume_step}, contract {contract_size}, min {volume_min}")
print(f"{risk} at risk over {stop_distance} -> {volume} lots")
if volume < volume_min:
print(f"refusing: {volume} is below the symbol's minimum of {volume_min}")
return
if not args.place:
print("not placed (pass --place). The volume above is exactly what would be sent.")
return
stop = None
result = await client.execution.place_order(
args.terminal_id,
symbol=args.symbol,
side=args.side,
volume=volume, # a Decimal: the SDK sends "0.21", never 0.21000000000000002
sl=stop,
idempotency_key=f"sizing-{uuid.uuid4()}",
)
# Every execution call returns a result and never raises: a timeout, a refusal and a broker
# rejection are all outcomes of trying to trade.
print(f"{result.state} ok={result.ok} command={result.command_id}")
if not result.ok:
print(f" reason={result.reason} retryable={result.retryable}")
if __name__ == "__main__":
asyncio.run(main())