Lesson 0.3.1 · 45 min
Variables, types and operators; floating point and decimal for prices and ticks
A price, a quantity and a ticker are data of different types, and Python treats them differently. The lesson introduces the basic types and their operators, and the first classic error of trading in code: floating point.
- Module
- 0.3 · Language fundamentals
- Duration
- 45 min
- Author
- Jesús Cuesta
- Updated
Level 0 lessons
0.1 · Welcome and expectations
0.2 · Modern working environment
- 0.2.1 Installing Python with uv: versions and support cycle
- 0.2.2 Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles
- 0.2.3 Editors (VS Code, Cursor, PyCharm) and notebooks (Jupyter, JupyterLab, marimo)
- 0.2.4 Basic terminal, paths, environment variables and .env files
- 0.2.5 Git and GitHub: commits, branches, pull requests and .gitignore
- 0.2.6 Introduction to AI coding assistants: prompts, review and tests
0.3 · Language fundamentals
- 0.3.1 Variables, types and operators; floating point and decimal for prices and ticks
- 0.3.2 Strings, f-strings and formatting numbers and dates
- 0.3.3 Lists, tuples, dictionaries, sets and comprehensions
- 0.3.4 Control flow: if, elif, else, for, while and match-case
- 0.3.5 Functions: arguments, *args and **kwargs, lambdas and scope
- 0.3.6 Exceptions; modules and packages, with Python 3.15 lazy imports
0.4 · Intermediate Python
- 0.4.1 Object-oriented programming: classes, inheritance, composition and dataclasses
- 0.4.2 Typing: type hints, mypy or ty, and Pydantic for configurations
- 0.4.3 Iterators, generators, decorators and context managers
- 0.4.4 Files (CSV, JSON, YAML, TOML) and dates with time zones
- 0.4.5 Professional logging and testing with pytest
- 0.4.6 Concurrency: threading, multiprocessing, asyncio and free-threading
0.5 · Numerical computing and data
- 0.5.1 NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators
- 0.5.2 pandas 3.0: Series, DataFrame, time indexes, Copy-on-Write, the str type and pd.col()
- 0.5.3 Selection, filtering, groupby, merge, pivot, resample and moving windows
- 0.5.4 Polars: lazy versus eager API, expressions, performance and when to pick it over pandas
- 0.5.5 DuckDB and essential SQL: SELECT, JOIN, GROUP BY and window functions on Parquet
0.6 · Visualisation
0.7 · Minimum mathematics and statistics
Variables: names for market data
A program works with data: the price of an asset, the quantity of an order, the symbol being traded. A variable is
a name that points to one of those pieces of data. It is created with the = sign, which in Python does not mean
“equal” but “assign the value on the right to this name”:
ticker = "SINT"
precio = 142.22
cantidad = 100
es_compra = True
print(ticker, precio, cantidad, es_compra)
print(type(ticker), type(precio), type(cantidad), type(es_compra))SINT 142.22 100 True
<class 'str'> <class 'float'> <class 'int'> <class 'bool'>Every value has a type, and type() tells you which. The four in the cell are the basic types that appear in any
trading program:
| Type | What it holds | Trading example | Python literal |
|---|---|---|---|
int | Integers, with no size limit | Shares in an order, contracts, volume | 100, -3, 2_322_609 |
float | Numbers with decimals (binary floating point) | Prices, returns, volatility | 142.22, -0.035, 1e-4 |
str | Text | Ticker, market, side of the order | "SINT", "buy" |
bool | True or false | Is there an open position? Was the stop hit? | True, False |
NoneType | The absence of a value | A stop that has not been set yet | None |
Three rules about names. They are written in lowercase with underscores (precio_entrada, not PrecioEntrada): it is
the Python convention and the one the whole course follows. They cannot start with a number or match reserved words
(if, for, class…). And they should say what they hold: p or x2 force you to read the rest of the code to know
what they are; precio_cierre does not.
In code, the decimal separator is the point (142.22), not the comma. An underscore inside a number only makes it
easier to read: 2_322_609 is the same int as 2322609.
A variable can be reassigned, and from that moment on it points to the new value. The type belongs to the value, not to the name:
stop = None # no stop yet
stop = 138.50 # now it is a float
print(stop, type(stop))138.5 <class 'float'>Python shows 138.5, not 138.50: a float does not store how many decimals were written, only the number. How to
always show two decimals is covered in lesson 0.3.2.
Arithmetic operators
Operators combine values. The arithmetic ones are those of a calculator, plus three that are used daily in trading:
integer division //, the remainder % and the power **.
| Operator | Operation | Example | Result |
|---|---|---|---|
+ - * | Addition, subtraction, multiplication | 142.22 * 100 | 14222.0 |
/ | Division (always float) | 10 / 2 | 5.0 |
// | Integer division (rounded down) | 1250 // 100 | 12 |
% | Remainder of integer division | 1250 % 100 | 50 |
** | Power | 1.05 ** 10 | 1.628… |
The value of a position, its result and its return come from multiplying, subtracting and dividing:
precio_entrada = 135.40
precio_actual = 142.22
cantidad = 100
valor = precio_actual * cantidad
pnl = (precio_actual - precio_entrada) * cantidad
rentabilidad = precio_actual / precio_entrada - 1
print(valor)
print(pnl)
print(rentabilidad)14222.0
681.9999999999993
0.050369276218611425The output already hints at the central topic of the lesson: the P&L, which should be exactly €682, shows up with a tail of decimals. Before explaining it, the other two operators.
Integer division and the remainder solve a problem that appears as soon as you compute the size of an order: many
orders only accept multiples of a lot, and futures are traded in whole contracts. // gives how many fit and % what
is left over:
acciones = 1_250
lote = 100
print("Full lots:", acciones // lote)
print("Odd shares:", acciones % lote)
capital = 25_000
garantia_contrato = 6_000
print("Contracts:", capital // garantia_contrato)
print("Unused capital:", capital % garantia_contrato)
print("Capital at 5% a year after 10 years:", 10_000 * 1.05 ** 10)Full lots: 12
Odd shares: 50
Contracts: 4
Unused capital: 1000
Capital at 5% a year after 10 years: 16288.94626777442Two details of this cell. print accepts several values separated by commas and writes them with a space between
them. And the order of operations is the mathematical one: first **, then *, /, // and %, and finally +
and -. That is why 10_000 * 1.05 ** 10 raises to the power before multiplying. When in doubt, parentheses.
There is a difference between / and // worth settling from the start: / always returns a float, even when the
division is exact (10 / 2 is 5.0). If the result is going to count shares or contracts, it has to come from //
or from an explicit rounding, never from simply turning a float into an int (the reason is shown below).
Comparisons and logic
The comparison operators (==, !=, <, <=, >, >=) return a bool, and and, or and not combine them.
They are the raw material of any trading rule:
cierre = 142.22
media_200 = 131.80
stop = 138.50
por_encima = cierre > media_200
stop_tocado = cierre <= stop
mantener = por_encima and not stop_tocado
print(por_encima, stop_tocado, mantener)True False TrueWatch the difference between = (assign) and == (compare). What to do with these conditions (buy, sell, do nothing)
is the subject of lesson 0.3.4.
Floating point: why 0.1 + 0.2 is not 0.3
A float is stored in binary, with 53 bits of precision (the IEEE 754 standard, the same on every computer). Just
as 1/3 has no finite decimal expansion (0.3333…), most decimal fractions have no finite binary expansion: in base 2,
0.1 is a repeating fraction. The computer stores the nearest binary number, and the difference shows up as soon as you
operate:
from decimal import Decimal
print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)
print(Decimal(0.1))
print(Decimal(142.22))0.30000000000000004
False
0.1000000000000000055511151231257827021181583404541015625
142.219999999999998863131622783839702606201171875Decimal(0.1) shows the exact value held in memory when you write 0.1: almost, but not exactly, one tenth. The same
happens with almost any price with decimals. print hides it because it shows the shortest number that gives back the
same float, but the stored value is the one on the last line.
In trading, this representation error causes three typical failures:
- Comparing with
==. A computed price (entrada + 3 * tick) may not equal the price that comes from the market even though both read the same. Anif precio == objetivocondition that is never met is an error that is hard to see. - Counting ticks with
int().intdoes not round: it cuts off the decimals. If the division gives 9.99999999999943…, the result is 9. - Rounding to the cent with
round().round(2.675, 2)gives2.67, because 2.675 is stored as 2.67499999…; besides,roundrounds ties to even (round(0.125, 2)is0.12).
tick = 0.01
print((101.03 - 101.00) / tick, int((101.03 - 101.00) / tick))
print((100.05 - 99.95) / tick, int((100.05 - 99.95) / tick))
print(round(2.675, 2), round(0.125, 2))
tick_futuro = 0.25
print((4500.75 - 4500.00) / tick_futuro)3.0000000000001137 3
9.999999999999432 9
2.67 0.12
3.0Between 99.95 and 100.05 there are ten ticks of 0.01; int counts nine. The last line, on the other hand, is exact:
0.25 is 1/4, a finite binary fraction, so prices in quarters are represented without error. Ticks of 0.01, 0.05 or
0.1 are not.
To compare floating-point prices, the rule is to compare with a tolerance (math.isclose(a, b) or
abs(a - b) < tick / 2) and, to count ticks, to round before converting: round((b - a) / tick).
How much the error accumulates
The error of each operation is on the order of 10⁻¹⁶ times the value. What matters is whether it grows when many
operations are added up, as in a trade ledger or in the P&L of a long backtest. The cell adds €0.10 of profit over and
over and measures the difference from the exact result (the for loop repeats the addition; it is explained in lesson
0.3.4):
from decimal import Decimal
for n in [10, 100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000]:
total = 0.0
for _ in range(n):
total += 0.1
exacto = Decimal(n) / 10
error = abs(Decimal(total) - exacto)
print(f"{n:>10,} {total!r:<20} error {float(error):.1e}") 10 0.9999999999999999 error 1.1e-16
100 9.99999999999998 error 2.0e-14
1,000 99.9999999999986 error 1.4e-12
10,000 1000.0000000001588 error 1.6e-10
100,000 10000.000000018848 error 1.9e-08
1,000,000 100000.00000133288 error 1.3e-06
10,000,000 999999.9998389754 error 1.6e-04total += 0.1 is a short way of writing total = total + 0.1. The header figure turns those errors into exact
decimals: how many decimals of the total match the exact sum. After ten additions, fifteen; after ten million,
three. Two conclusions, both important:
- For statistics, returns and backtests,
floatis enough. An error in the fourth decimal after ten million additions does not change any decision, and NumPy and pandas (module 0.5) work infloatfor speed. - For order prices, account balances and reconciliation with the broker, it is not enough. There the problem is not the size of the error but that it exists at all: a price that does not fall on the tick grid or a balance that does not reconcile to the cent is an error, however small.
Decimal: exact prices and rounding to the tick
The standard library's decimal module represents numbers in base 10, as in accounting. A Decimal created from a
string stores exactly what is written:
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2") == Decimal("0.3"))
print(Decimal("0.1"), Decimal(0.1) == Decimal("0.1"))
entrada = Decimal("135.40")
salida = Decimal("142.22")
pnl = (salida - entrada) * 100
print(pnl)
print((Decimal("100.05") - Decimal("99.95")) / Decimal("0.01"))True
0.1 False
682.00
10The P&L comes out exact, with its two decimals, and the tick count is 10. The second line shows the trap:
Decimal(0.1), without quotes, copies the float with its error. Prices are passed to Decimal as text: as they
arrive from the broker or the file, or with Decimal(str(precio)) if they are already floats.
There are two cases for bringing a price onto the tick grid. If the tick is a power of ten (0.01, 0.001), quantize
is enough: it rounds to the decimals of the value it receives. If it is not (0.05, 0.25), you divide by the tick, round
to an integer and multiply back. In both cases, the rounding mode depends on the side of the order: a buy limit is
rounded down, so as not to pay more than the computed price, and a sell limit is rounded up.
from decimal import ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_EVEN
precio = Decimal("101.237")
print(precio.quantize(Decimal("0.01")))
print(precio.quantize(Decimal("0.01"), rounding=ROUND_FLOOR))
tick = Decimal("0.05")
unidades = precio / tick
compra = unidades.quantize(Decimal("1"), rounding=ROUND_FLOOR) * tick
venta = unidades.quantize(Decimal("1"), rounding=ROUND_CEILING) * tick
print("Buy limit:", compra, " Sell limit:", venta)
print(Decimal("2.675").quantize(Decimal("0.01")),
Decimal("2.665").quantize(Decimal("0.01"),
rounding=ROUND_HALF_EVEN))101.24
101.23
Buy limit: 101.20 Sell limit: 101.25
2.68 2.66Without rounding, quantize uses the default mode, ROUND_HALF_EVEN: the nearest and, in case of a tie, the even
one (“banker's rounding”, which does not bias totals upward). With Decimal, 2.675 really is a tie and rounds to
2.68; 2.665 rounds to 2.66, the even one. ROUND_HALF_UP is the rounding taught at school; ROUND_FLOOR and
ROUND_CEILING, down and up.
Exactness has a price. Decimal is slower than float and does not mix with it: adding a Decimal and a float is
an error, so that nobody slips the binary error into an exact account by accident. Python signals it with an exception
(exceptions are covered in lesson 0.3.6):
Decimal("142.22") + 0.01TypeError: unsupported operand type(s) for +: 'decimal.Decimal' and 'float'And the precision of Decimal is finite too: 28 significant digits by default. Divisions that are not exact are
rounded there, so Decimal does not fix 1/3; it fixes the decimals written in base 10, which are the ones prices use.
Which type to use in each case
| Data | Type | Why |
|---|---|---|
| Shares, contracts, lots | int | They are integers: with // and %, never with int() on a float |
| Returns, volatility, signals | float | Speed; a relative error of 10⁻¹⁶ does not change any decision |
| Order prices, ticks | Decimal or int in ticks | They have to fall exactly on the market's grid |
| Balances, commissions, taxes | Decimal | They must reconcile to the cent with the broker's statement |
| Prices in a vectorized backtest | float (NumPy/pandas) | They are compared with a tolerance and rounded to the tick when the order is generated |
The second option for prices, storing the number of ticks as an int (10,123 ticks of 0.01 instead of 101.23), is
the one many exchange matching engines use: integers are exact and fast.
Exercises
- An account has €47,300 and each futures contract requires €5,500 of margin. Compute how many contracts can be opened, how much capital is left unused and what percentage of the account is committed as margin.
Solution
capital = 47_300
garantia = 5_500
contratos = capital // garantia
print("Contracts:", contratos)
print("Unused:", capital % garantia)
print("Committed:", contratos * garantia / capital)Contracts: 8
Unused: 3300
Committed: 0.9302325581395349Eight contracts and €3,300 unused: a little over 93% of the account is held as margin. That you can does not mean you should: position sizing is decided by risk (level 4), not by the available margin.
- A buy price computed by a strategy is 87.4361 and the market quotes in ticks of 0.05. With
Decimal, compute the buy limit price (rounded down) and the sell limit price (rounded up), and check that both fall on the grid: the remainder of dividing them by the tick has to be zero.
Solution
from decimal import Decimal, ROUND_CEILING, ROUND_FLOOR
precio = Decimal("87.4361")
tick = Decimal("0.05")
uno = Decimal("1")
compra = (precio / tick).quantize(uno, rounding=ROUND_FLOOR) * tick
venta = (precio / tick).quantize(uno, rounding=ROUND_CEILING) * tick
print(compra, venta)
print(compra % tick, venta % tick)87.40 87.45
0.00 0.00The % operator also works with Decimal. With float, the same remainder would give a tiny number instead of zero
and the check would fail.
- Count the ticks of 0.01 between 99.95 and 100.05 in three ways: with
int()on thefloat, withround()on thefloatand withDecimal. Which one fails and why?
Solution
from decimal import Decimal
print(int((100.05 - 99.95) / 0.01))
print(round((100.05 - 99.95) / 0.01))
print((Decimal("100.05") - Decimal("99.95")) / Decimal("0.01"))9
10
10int() fails: it truncates 9.99999999999943… to 9. round() fixes it because the error is much smaller than half a
tick, and Decimal gives the exact result from the start.
Each answer is folded under its question
Self-assessment
An account has €25,000 and each futures contract requires €6,000 of margin. Which expression gives the number of contracts that can be opened?
- A
25_000 // 6_000 - B
25_000 / 6_000 - C
25_000 % 6_000 - D
round(25_000 / 6_000)
Show answer
Answer A.
25_000 // 6_000//is integer division: 4 contracts./gives 4.1666…,%gives the remainder (€1,000) androundwould give 4 here, but with 5.5 contracts it would round up and open one that cannot be covered.- A
What does
0.1 + 0.2 == 0.3return in Python?- A
True - BA type error
- C
False, because 0.1 and 0.2 have no exact binary representation - DIt depends on the operating system
Show answer
Answer C.
False, because 0.1 and 0.2 have no exact binary representationFloats are stored in binary, and 0.1 is a repeating fraction in base 2. The sum gives 0.30000000000000004. The result is the same on any machine: it is set by the IEEE 754 standard.
- A
A
floatprice is 10 ticks of 0.01 above another, butint((100.05 - 99.95) / 0.01)returns 9. Why?- ABecause
introunds to the nearest integer - BBecause the floating-point subtraction gives 9.99999999999943… and
inttruncates the decimals - CBecause 0.01 is not a valid tick
- DBecause of an interpreter bug
Show answer
Answer B. Because the floating-point subtraction gives 9.99999999999943… and
inttruncates the decimalsint()cuts off the decimals; it does not round. With a representation error just below the integer, the tick count loses one. WithDecimalcreated from strings, the subtraction is exact.- ABecause
You have to send a buy limit order, and the computed price, 101.237, is not on the 0.05 tick grid. Which rounding is the prudent one?
- AUp, to 101.25, so that it fills sooner
- BAlways to the nearest, regardless of the side
- CNo rounding: the broker accepts it
- DDown, to 101.20, so as not to pay more than the computed price
Show answer
Answer D. Down, to 101.20, so as not to pay more than the computed price
A buy limit rounded down never pays more than computed; a sell, rounded up. If the price does not fall on the grid, the broker rejects the order.
Key points
- Every value has a type:
intfor quantities,floatfor calculations,strfor tickers andboolfor conditions.type()shows it. /always gives afloat;//and%give the integer part and the remainder: full lots, contracts and odd shares.- Floats are binary:
0.1 + 0.2is not0.3. They are never compared with==or converted withint()without rounding first. - A float's error is tiny and accumulates slowly: for statistics and vectorized backtests it is enough. For order prices and accounting,
Decimal. Decimalis created from strings, not from floats, andquantizerounds to the tick with the mode the side of the order calls for.