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.

Fig. — Exact decimals of the total after adding 0.1 n times in floating point (64-bit float)
01/06
Module
0.3 · Language fundamentals
Duration
45 min
Updated
Level 0 lessons
  1. 0.1 · Welcome and expectations

    1. 0.1.1 Systematic, algorithmic and quantitative trading versus discretionary trading
    2. 0.1.2 Realistic expectations: costs, time and risk of loss
    3. 0.1.3 Course structure, entry tests, personal repository and learning with AI
  2. 0.2 · Modern working environment

    1. 0.2.1 Installing Python with uv: versions and support cycle
    2. 0.2.2 Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles
    3. 0.2.3 Editors (VS Code, Cursor, PyCharm) and notebooks (Jupyter, JupyterLab, marimo)
    4. 0.2.4 Basic terminal, paths, environment variables and .env files
    5. 0.2.5 Git and GitHub: commits, branches, pull requests and .gitignore
    6. 0.2.6 Introduction to AI coding assistants: prompts, review and tests
  3. 0.3 · Language fundamentals

    1. 0.3.1 Variables, types and operators; floating point and decimal for prices and ticks
    2. 0.3.2 Strings, f-strings and formatting numbers and dates
    3. 0.3.3 Lists, tuples, dictionaries, sets and comprehensions
    4. 0.3.4 Control flow: if, elif, else, for, while and match-case
    5. 0.3.5 Functions: arguments, *args and **kwargs, lambdas and scope
    6. 0.3.6 Exceptions; modules and packages, with Python 3.15 lazy imports
  4. 0.4 · Intermediate Python

    1. 0.4.1 Object-oriented programming: classes, inheritance, composition and dataclasses
    2. 0.4.2 Typing: type hints, mypy or ty, and Pydantic for configurations
    3. 0.4.3 Iterators, generators, decorators and context managers
    4. 0.4.4 Files (CSV, JSON, YAML, TOML) and dates with time zones
    5. 0.4.5 Professional logging and testing with pytest
    6. 0.4.6 Concurrency: threading, multiprocessing, asyncio and free-threading
  5. 0.5 · Numerical computing and data

    1. 0.5.1 NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators
    2. 0.5.2 pandas 3.0: Series, DataFrame, time indexes, Copy-on-Write, the str type and pd.col()
    3. 0.5.3 Selection, filtering, groupby, merge, pivot, resample and moving windows
    4. 0.5.4 Polars: lazy versus eager API, expressions, performance and when to pick it over pandas
    5. 0.5.5 DuckDB and essential SQL: SELECT, JOIN, GROUP BY and window functions on Parquet
  6. 0.6 · Visualisation

    1. 0.6.1 Object-oriented Matplotlib, Seaborn and Plotly: price, drawdown and candlestick charts
    2. 0.6.2 Quick dashboards with Streamlit: a monitoring dashboard in a single script
  7. 0.7 · Minimum mathematics and statistics

    1. 0.7.1 Simple and log returns, compound interest and descriptive statistics
    2. 0.7.2 Probability and distributions, correlation versus causation and simple regression
    3. 0.7.3 Intuitive linear algebra: vectors and matrices as groundwork for portfolios

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”:

IN [01]python
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))
OUT [01]Four variables of an order
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:

TypeWhat it holdsTrading examplePython literal
intIntegers, with no size limitShares in an order, contracts, volume100, -3, 2_322_609
floatNumbers with decimals (binary floating point)Prices, returns, volatility142.22, -0.035, 1e-4
strTextTicker, market, side of the order"SINT", "buy"
boolTrue or falseIs there an open position? Was the stop hit?True, False
NoneTypeThe absence of a valueA stop that has not been set yetNone

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:

IN [02]python
stop = None          # no stop yet
stop = 138.50        # now it is a float
print(stop, type(stop))
OUT [02]Reassigning a variable
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 **.

OperatorOperationExampleResult
+ - *Addition, subtraction, multiplication142.22 * 10014222.0
/Division (always float)10 / 25.0
//Integer division (rounded down)1250 // 10012
%Remainder of integer division1250 % 10050
**Power1.05 ** 101.628…

The value of a position, its result and its return come from multiplying, subtracting and dividing:

IN [03]python
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)
OUT [03]Value, P&L and return of a position
14222.0
681.9999999999993
0.050369276218611425

The 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:

IN [04]python
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)
OUT [04]Lots and contracts
Full lots: 12
Odd shares: 50
Contracts: 4
Unused capital: 1000
Capital at 5% a year after 10 years: 16288.94626777442

Two 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:

IN [05]python
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)
OUT [05]Conditions of a rule
True False True

Watch 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:

IN [06]python
from decimal import Decimal

print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)
print(Decimal(0.1))
print(Decimal(142.22))
OUT [06]The representation error
0.30000000000000004
False
0.1000000000000000055511151231257827021181583404541015625
142.219999999999998863131622783839702606201171875

Decimal(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. An if precio == objetivo condition that is never met is an error that is hard to see.
  • Counting ticks with int(). int does 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) gives 2.67, because 2.675 is stored as 2.67499999…; besides, round rounds ties to even (round(0.125, 2) is 0.12).
IN [07]python
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)
OUT [07]Ticks counted with float
3.0000000000001137 3
9.999999999999432 9
2.67 0.12
3.0

Between 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):

IN [08]python
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}")
OUT [08]Adding €0.10 many times
        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-04

total += 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, float is enough. An error in the fourth decimal after ten million additions does not change any decision, and NumPy and pandas (module 0.5) work in float for 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:

IN [09]python
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"))
OUT [09]Decimal from strings
True
0.1 False
682.00
10

The 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.

IN [10]python
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))
OUT [10]Rounding to the tick by side
101.24
101.23
Buy limit: 101.20  Sell limit: 101.25
2.68 2.66

Without 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):

IN [11]python
Decimal("142.22") + 0.01
OUT [11]Decimal and float do not mix
TypeError: 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

DataTypeWhy
Shares, contracts, lotsintThey are integers: with // and %, never with int() on a float
Returns, volatility, signalsfloatSpeed; a relative error of 10⁻¹⁶ does not change any decision
Order prices, ticksDecimal or int in ticksThey have to fall exactly on the market's grid
Balances, commissions, taxesDecimalThey must reconcile to the cent with the broker's statement
Prices in a vectorized backtestfloat (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

  1. 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
IN [12]python
capital = 47_300
garantia = 5_500
contratos = capital // garantia
print("Contracts:", contratos)
print("Unused:", capital % garantia)
print("Committed:", contratos * garantia / capital)
OUT [12]Contracts with €47,300
Contracts: 8
Unused: 3300
Committed: 0.9302325581395349

Eight 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.

  1. 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
IN [13]python
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)
OUT [13]0.05 grid
87.40 87.45
0.00 0.00

The % operator also works with Decimal. With float, the same remainder would give a tiny number instead of zero and the check would fail.

  1. Count the ticks of 0.01 between 99.95 and 100.05 in three ways: with int() on the float, with round() on the float and with Decimal. Which one fails and why?
Solution
IN [14]python
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"))
OUT [14]Three ways to count ticks
9
10
10

int() 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

  1. 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?

    • A25_000 // 6_000
    • B25_000 / 6_000
    • C25_000 % 6_000
    • Dround(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) and round would give 4 here, but with 5.5 contracts it would round up and open one that cannot be covered.

  2. What does 0.1 + 0.2 == 0.3 return in Python?

    • ATrue
    • BA type error
    • CFalse, 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 representation

    Floats 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.

  3. A float price is 10 ticks of 0.01 above another, but int((100.05 - 99.95) / 0.01) returns 9. Why?

    • ABecause int rounds to the nearest integer
    • BBecause the floating-point subtraction gives 9.99999999999943… and int truncates 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 int truncates the decimals

    int() cuts off the decimals; it does not round. With a representation error just below the integer, the tick count loses one. With Decimal created from strings, the subtraction is exact.

  4. 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: int for quantities, float for calculations, str for tickers and bool for conditions. type() shows it.
  • / always gives a float; // and % give the integer part and the remainder: full lots, contracts and odd shares.
  • Floats are binary: 0.1 + 0.2 is not 0.3. They are never compared with == or converted with int() 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.
  • Decimal is created from strings, not from floats, and quantize rounds to the tick with the mode the side of the order calls for.

Resources