Almost every trading bot has one line that decides how much to buy. It usually looks trivial: if I risk 1% of my balance and my stop is 1000 dollars away, the quantity comes out of a division. The arithmetic is grade-school.
What isn't grade-school is making that number fit the exchange's constraints. That's where money is lost, and in ways that don't show up in the logs.
The pattern that multiplies your risk
This is in countless bots, and was in one of mine:
cantidad = max(1, int(cantidad_teorica / contract_size))
The intent is defensive: "never let it come out zero". The effect is the opposite.
If the theoretical quantity comes out to 0.1 contracts, int() truncates it to 0, and then max(1, ...) forces it to one. You've just opened a position ten times larger than the one you authorised.
With concrete numbers: 500 USD balance, 1% risk (5 USD), entry at 50,000, stop at 45,000, 0.01 contracts. The real risk of that single contract is 50 USD — ten times the budget. And it happens silently: the order is accepted, the bot carries on, there's no exception to catch.
The worst part is that it's not a rare case. It happens whenever the balance is small or the stop is wide, that is, exactly when you have the least room for error.
The other two that cost money
Ignoring the minimum notional. The exchange rejects the order for minimum value, the bot logs it as a network error, and nobody realises that signal was never traded. The backtest counted it; the account didn't.
Not reserving for fees. With a tight stop, round-trip fees can be half the real risk. If you size against the distance to the stop and nothing else, you systematically risk more than you think.
How I solved it
I pulled the calculation out of the bot and released it as a library: position-sizing (Apache-2.0, no dependencies).
from decimal import Decimal
from position_sizing import MarketSpec, size_for_risk
spec = MarketSpec(
amount_step=Decimal("0.001"),
min_amount=Decimal("0.001"),
min_notional=Decimal("5"),
)
r = size_for_risk(
balance=Decimal("1000"),
risk_fraction=Decimal("0.01"),
entry=Decimal("50000"),
stop=Decimal("49000"),
spec=spec,
fee_rate=Decimal("0.0005"),
)
if r.ok:
exchange.create_order(symbol, "market", "buy", float(r.quantity))
else:
log.warning("sin operar: %s", r.reason)
With ccxt you don't have to write the MarketSpec by hand:
from position_sizing import spec_from_ccxt
spec = spec_from_ccxt(exchange.market("BTC/USDT:USDT"))
Two decisions that matter more than the code
Decimal, not float, and always round down. The bug is born of an accidental truncation. With decimal and explicit rounding, the direction stops being an accident of the type and becomes a decision — and the right decision is down, because the error is asymmetric: going over budget is paid in money, falling short only costs a little performance.
Pure function: it takes a MarketSpec, not an exchange. It tests without a network, works for any exchange and — most importantly — computes the same in backtest as in production. A backtest that assumes fractional positions the market would never have accepted inflates the return; using the same function on both sides, that lie disappears. (That's what another article is about.)
Refusing to trade is also a result
When the market's minimum quantity doesn't fit the risk budget, the library doesn't trade. But it doesn't just return None: it says how much balance would be needed for it to fit.
the market's minimum quantity risks more than budgeted;
5000.00 of balance would be needed
A None forces you to reconstruct the why by reading the code six months later. A reason with the figure inside is read in the log and acted on. Refusing is fine; refusing with an explanation is better.
What it does NOT do
It sizes orders. It doesn't decide when to trade, nor where to put the stop, nor in which direction. It's arithmetic over exchange constraints, not a strategy.
That boundary is deliberate: the part that gives an edge isn't published, but the plumbing we all rewrite wrong is. If your bot has a max(1, int(...)) in the order path, review it today.
Xiliux