A gold pip calculator looks trivial until you run it against two brokers and get numbers ten times apart. This post builds one in Python, keeps both conventions explicit, and shows the small test suite that stops the 10x bug from shipping.
CONTRACT = {
"XAUUSD": 100.0, # ounces per standard lot
}
def pip_value(symbol: str, lots: float, convention: float, price: float = 0.0) -> float:
"""Value of one pip, in account currency, for a gold position.
convention: 0.10 (common retail quote, "pip" = 10 cents)
0.01 (two-decimal brokers, "pip" = 1 cent)
"""
if symbol not in CONTRACT:
raise ValueError(f"no contract spec for {symbol}")
if convention not in (0.10, 0.01):
raise ValueError("convention must be 0.10 or 0.01")
ounces = CONTRACT[symbol] * lots
return ounces * convention
Three lines of arithmetic. The reason people get it wrong is not the arithmetic — it is that lots and convention arrive from different places, and neither carries its unit.
The two conventions, side by side
| Position | Ounces | Pip value @ 0.10 | Pip value @ 0.01 |
|---|---|---|---|
| 1.00 lot | 100 | $10.00 | $1.00 |
| 0.10 lot | 10 | $1.00 | $0.10 |
| 0.01 lot | 1 | $0.10 | $0.01 |
Same position, same move, two labels. A trader who says "I made 50 pips" on a 0.1 lot means $50 at one broker and $5 at the other. Neither is lying. This is why a calculator that takes a bare pip_value number as input is worse than no calculator.
Position size, derived from risk instead of guessed
The usual reason to compute pip value is to answer "how many lots may I trade". Do it from the risk budget and the stop distance, not from a round number that feels safe:
def position_size(
symbol: str,
risk_money: float,
stop_distance: float,
convention: float,
) -> float:
"""Lots such that a stop_distance move loses exactly risk_money."""
per_lot_per_unit = CONTRACT[symbol] * convention / convention # = contract size
# dollars lost per 1.00 price move, per lot
dollars_per_unit_per_lot = CONTRACT[symbol]
lots = risk_money / (stop_distance * dollars_per_unit_per_lot / convention * convention)
return round(lots, 2)
That middle line is deliberately ugly, because it is where the bug hides. stop_distance is in price terms — dollars of gold, not pips. Mixing the two units is the 10x error. Simplify by working in price terms only:
def position_size_simple(symbol: str, risk_money: float, stop_in_price: float) -> float:
"""Lots such that a stop_in_price move (in gold dollars) loses risk_money."""
dollars_per_lot_per_dollar = CONTRACT[symbol]
return round(risk_money / (stop_in_price * dollars_per_lot_per_dollar), 2)
Now the convention drops out entirely, which is the point: pip counting is a display layer. Risk lives in price and contract size. If your sizing function needs the pip convention to be correct, it is measuring the wrong thing.
The tests that catch the 10x bug
import pytest
def test_pip_value_reference_points():
assert pip_value("XAUUSD", 1.0, 0.10) == pytest.approx(10.0)
assert pip_value("XAUUSD", 0.1, 0.10) == pytest.approx(1.0)
assert pip_value("XAUUSD", 0.01, 0.01) == pytest.approx(0.01)
def test_conventions_differ_by_ten():
a = pip_value("XAUUSD", 0.1, 0.10)
b = pip_value("XAUUSD", 0.1, 0.01)
assert a / b == pytest.approx(10.0)
def test_sizing_is_convention_independent():
# $500 account, 1% risk, $5 stop
assert position_size_simple("XAUUSD", 5.0, 5.0) == pytest.approx(0.01)
def test_sizing_rejects_zero_stop():
with pytest.raises(ZeroDivisionError):
position_size_simple("XAUUSD", 5.0, 0.0)
The third test is the one that matters. A $500 account risking 1% with a $5 stop may trade 0.01 lot — one ounce — and no convention argument changes that. If your calculator returns 0.1 lot here, it is computing pip value correctly and risk incorrectly.
Where the cost breaks the model
Everything above ignores spread, commission and slippage, which is fine for sizing and wrong for break-even. On 0.1 lot a 20-cent spread is $2, so a $5 risk budget is already 40% consumed before the trade opens. Add that to the pipeline as a separate step rather than folding it into pip value:
def break_even_distance(lots: float, spread_price: float, commission_rt: float) -> float:
"""Price move needed to cover round-trip cost, in gold dollars."""
ounces = CONTRACT["XAUUSD"] * lots
cost_money = ounces * spread_price + commission_rt * lots
return cost_money / ounces
At 0.1 lot with a 20-cent spread and $6 per round turn on a standard lot, break-even sits about 26 cents away from entry. That number belongs next to the pip value on any honest calculator, and almost none of them show it.
What I would keep
Keep the convention as an explicit parameter with no default. A default silently turns a unit error into a plausible number, and plausible numbers are the expensive kind. Compute pip value for display, compute position size from price and contract size, and compute break-even from real costs. Three functions, three units, no shared assumptions.
Educational code, not investment advice. Contract sizes and commission structures vary by broker — verify against your own specification before sizing a live position.
Top comments (0)