Lesson 0.3.4 · 50 min
Control flow: if, elif, else, for, while and match-case
A trading rule is a decision repeated on every bar. This lesson shows how to write decisions with if, repeat them with for and while, and dispatch different order types with match, using SINT's daily prices.
- Module
- 0.3 · Language fundamentals
- Duration
- 50 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
A program that decides and repeats
So far the code ran from top to bottom, one line after another. A trading rule needs two more things: to
decide (“if the close is above the moving average, buy”) and to repeat (“for each session, check whether the
stop was hit”). Control flow statements change the order in which code runs: if chooses between paths, for
and while repeat, and match chooses according to the shape of a value.
The examples use SINT's daily bars. The first three lines of the cell read the file with the standard library's csv
module; files are covered in lesson 0.4.4, and pandas, which does this in one line, in module 0.5. What matters is
the result: a list of (fecha, apertura, maximo, minimo, cierre) tuples (date, open, high, low, close), one per
session, like the ones in lesson 0.3.3.
import csv
with open("sint_diario.csv", encoding="utf-8") as f:
filas = list(csv.DictReader(f))
barras = [(r["fecha"], float(r["apertura"]), float(r["maximo"]),
float(r["minimo"]), float(r["cierre"])) for r in filas]
print(len(barras))
print(barras[-1])1303
('2025-12-31', 140.01, 143.4, 139.67, 142.22)Deciding with if, elif and else
A bar is bullish if it closes above its open, bearish if it closes below it, and a doji if the body (the distance between open and close) is very small relative to the day's range. With a threshold of 10% of the range, the classification is a chain of conditions:
fecha, apertura, maximo, minimo, cierre = barras[-1]
cuerpo = abs(cierre - apertura)
rango = maximo - minimo
if rango == 0 or cuerpo <= 0.10 * rango:
tipo = "doji"
elif cierre > apertura:
tipo = "bullish"
else:
tipo = "bearish"
print(fecha, tipo, f"body {cuerpo:.2f} / range {rango:.2f}")2025-12-31 bullish body 2.21 / range 3.73Four syntax rules:
- The condition ends with a colon, and the code that depends on it is indented four spaces. In Python indentation is not cosmetic: it is what says which lines belong to each branch. A wrongly indented line changes the program.
- Only the first branch whose condition is true runs. That is why the doji goes first: a bar with a tiny body and
the close above the open would also satisfy
cierre > apertura. elif(“else if”) andelseare optional; there can be as manyelifas needed and a singleelse, at the end.rango == 0handles a bar with no range (a day without trading) separately: it is classified as a doji with no further calculation.orstops evaluating as soon as it finds something true.
When the decision only chooses between two values, the conditional expression does it in one line:
lado = "largo" # long; "corto" is short
signo = 1 if lado == "largo" else -1
pnl = signo * (cierre - 139.98) * 100
print(signo, f"{pnl:+.2f}")1 +224.00Conditions do not have to be comparisons. In an if, an empty collection, 0, the empty string and None count as
false, and everything else as true. if not ordenes_pendientes: reads “if there are no pending orders”. To check
whether a value is missing, the correct form is if stop is None:, not if not stop:: a stop at 0 is not the same as
having no stop.
Repeating with for
for iterates over a collection and runs its block once per element. With range(n) it goes through the numbers
from 0 to n−1, and with enumerate it yields both the position and the element:
conteo = {"bullish": 0, "bearish": 0, "doji": 0}
for fecha, apertura, maximo, minimo, cierre in barras:
if not fecha.startswith("2025"):
continue
rango = maximo - minimo
if rango == 0 or abs(cierre - apertura) <= 0.10 * rango:
conteo["doji"] += 1
elif cierre > apertura:
conteo["bullish"] += 1
else:
conteo["bearish"] += 1
print(conteo, sum(conteo.values())){'bullish': 124, 'bearish': 109, 'doji': 28} 261The unpacking from lesson 0.3.3 works in the for header: each tuple is split into five variables. continue jumps
to the next element without running the rest of the block: here it discards the bars that are not from 2025. The
conteo dictionary accumulates the result, a common practice for counting by category.
A for goes through the whole collection unless something cuts it short. break exits the loop immediately, and it
is what you use to find the first time something happens. A for also accepts an else, which runs only if the
loop finished without break, that is, if what was being searched for was not found.
Stop and target: break and the loop's else
A long position with a stop and a target is closed in the first session in which the price touches either of them. With daily bars, the stop is hit if the day's low reaches the stop, and the target if the high reaches the target. The order of the checks matters:
Mermaid source
flowchart TB
B[Next bar] --> S{Low ≤ stop?}
S -- yes --> XS[Exit at the stop]
S -- no --> O{High ≥ target?}
O -- yes --> XO[Exit at target]
O -- no --> BThe stop is checked first for the sake of prudence. If in the same bar the low touches the stop and the high touches the target, daily data do not say what happened first. Assuming it was the target means picking the favorable outcome, and a backtest that always picks the favorable outcome gets inflated; the conservative assumption is the stop.
The entry in the example is a buy at the close of May 2, 2025, with a stop 3% below and a target 6% above:
indice = {b[0]: i for i, b in enumerate(barras)}
i_entrada = indice["2025-05-02"]
entrada = barras[i_entrada][4]
stop, objetivo = entrada * 0.97, entrada * 1.06
print(f"Entry {entrada:.2f} stop {stop:.2f} "
f"target {objetivo:.2f}")
for i, (fecha, ap, mx, mn, ci) in enumerate(barras):
if i <= i_entrada:
continue
if mn <= stop:
print(f"{fecha}: stop hit (low {mn:.2f})")
break
elif mx >= objetivo:
print(f"{fecha}: target hit (high {mx:.2f})")
break
else:
print("Neither stop nor target: position open")
print("Sessions in position:", i - i_entrada)Entry 142.51 stop 138.23 target 151.06
2025-05-07: stop hit (low 134.69)
Sessions in position: 3The position lasts three sessions: on May 7 the low, 134.69, goes through the stop at 138.23. That session opened at 139.22, above the stop, so the sale would have been made at the stop and not lower.
The first line is a dictionary comprehension (lesson 0.3.3) that maps each date to its position in the list: that is
how the entry session is found without going through the list by hand. In the for header, enumerate yields the
position i and the tuple, which is unpacked inside parentheses. Sessions before the entry are skipped with
continue; so is the entry session itself, because the buy happens at its close and that day's low and high had
already occurred.
Trailing stop with while
A trailing stop moves up with the price and never down: it sits a fixed percentage below the highest close since
the entry. It protects part of the gain without setting a target. You do not know in advance how many sessions the
position will last, and that is what a while loop expresses: it repeats its block as long as the condition
is true.
i = indice["2023-11-01"]
entrada = barras[i][4]
maximo_cierre = entrada
trailing = entrada * 0.95
abierta = True
while abierta and i + 1 < len(barras):
i += 1
fecha, ap, mx, mn, ci = barras[i]
if mn <= trailing:
salida = min(ap, trailing) # if it opens below, at the open
abierta = False
else:
maximo_cierre = max(maximo_cierre, ci)
trailing = max(trailing, maximo_cierre * 0.95)
print(f"Entry {entrada:.2f} on {barras[indice['2023-11-01']][0]}")
print(f"Exit {salida:.2f} on {fecha}, "
f"highest close {maximo_cierre:.2f}")
print(f"Result {salida / entrada - 1:+.2%} "
f"in {i - indice['2023-11-01']} sessions")Entry 115.27 on 2023-11-01
Exit 126.71 on 2024-02-23, highest close 133.38
Result +9.93% in 82 sessionsThe position is held for 82 sessions. The highest close reaches 133.38 and drags the stop up to 126.71; on February 23, 2024 the low, 126.55, touches it, and the exit leaves +9.93%. The figure at the top draws the path: SINT's close, the trailing stop following it in steps and the exit in amber.
Three details of the loop. The condition has two parts: the position is still open and there are bars left;
without the second, if the stop never triggered, barras[i] would end up asking for a bar that does not exist. The
trailing stop is updated with the close only after checking the low, so that a day's close is not used to decide
whether the stop was hit that same day. And min(ap, trailing) applies the earlier warning: if the session opens
below the stop, the sale happens at the open.
while versus for: if you go through a known collection, for; if you repeat until something happens, while. A
while whose condition never becomes false is an infinite loop: the program hangs. In a system that trades, every
while needs a safe exit (a maximum number of attempts, a time limit).
Dispatching orders with match-case
A system receives orders of several types, and each type needs different fields: a market order, side and quantity;
a limit order, also a limit price; a stop order, a trigger price. With if/elif you would have to check the type
and then, one by one, that the fields are present. match-case (Python 3.10) compares the shape of the
value against a series of patterns and, if it matches, binds its parts to variables. The orders keep the course's
data keys and values: tipo (type: "mercado" market, "limite" limit, "stop"), lado (side: "compra" buy,
"venta" sell), cantidad (quantity) and precio (price):
ordenes = [
{"tipo": "mercado", "lado": "compra", "cantidad": 100},
{"tipo": "limite", "lado": "venta", "cantidad": 50,
"precio": 145.20},
{"tipo": "stop", "lado": "venta", "cantidad": 100,
"precio": 135.00},
{"tipo": "limite", "lado": "compra", "cantidad": 10,
"precio": -1},
{"tipo": "iceberg", "lado": "compra", "cantidad": 5000},
]
for orden in ordenes:
match orden:
case {"tipo": "mercado", "lado": lado, "cantidad": q}:
print(f"MARKET {lado} {q}")
case {"tipo": "limite", "lado": lado, "cantidad": q,
"precio": p} if p > 0:
print(f"LIMIT {lado} {q} at {p:.2f}")
case {"tipo": "stop", "lado": "venta", "cantidad": q,
"precio": p}:
print(f"STOP sell {q} if it falls to {p:.2f}")
case {"tipo": "limite" | "stop"}:
print(f"REJECTED: invalid price or side {orden}")
case _:
print(f"REJECTED: unknown type {orden['tipo']!r}")MARKET compra 100
LIMIT venta 50 at 145.20
STOP sell 100 if it falls to 135.00
REJECTED: invalid price or side {'tipo': 'limite', 'lado': 'compra', 'cantidad': 10, 'precio': -1}
REJECTED: unknown type 'iceberg'How to read the patterns:
- A mapping pattern matches if the value has at least those keys with values that match. Extra keys do not matter.
- A literal value (
"mercado","venta") has to be equal; a name (lado,q,p) captures whatever value is there and leaves it in that variable for the branch. if p > 0after the pattern is a guard: the case only applies if, besides matching, the condition holds. The limit order with price −1 matches the shape, fails the guard and keeps going down."limite" | "stop"matches either of the two values. This case collects the limit and stop orders that did not pass the previous ones (such as a buy stop, which this system does not accept).case _matches everything: it is theelseofmatch. Without it, an order that matches nothing is silently ignored.
Cases are tried in order and only the first one that matches runs, just as in if/elif. That is why the general
patterns ("limite" | "stop", _) go at the end: placed at the beginning, they would swallow the valid orders.
Exercises
- Count how many sessions in 2024 closed with a rise of more than 2% over the previous close, and what the largest rise of the year was, with its date.
Solution
grandes = 0
mayor, fecha_mayor = 0.0, None
for i in range(1, len(barras)):
fecha, cierre = barras[i][0], barras[i][4]
if not fecha.startswith("2024"):
continue
r = cierre / barras[i - 1][4] - 1
if r > 0.02:
grandes += 1
if r > mayor:
mayor, fecha_mayor = r, fecha
print(grandes, fecha_mayor, f"{mayor:+.2%}")3 2024-07-17 +2.50%range(1, len(barras)) starts at 1 because the first bar has no previous close. The return of the first session of
2024 uses the last close of 2023, which is correct.
- Repeat the trailing stop with 8% instead of 5%, from the same entry. When does it exit and with what result? Why does the number of sessions change?
Solution
i = indice["2023-11-01"]
entrada = barras[i][4]
maximo_cierre, trailing, abierta = entrada, entrada * 0.92, True
while abierta and i + 1 < len(barras):
i += 1
fecha, ap, mx, mn, ci = barras[i]
if mn <= trailing:
salida, abierta = min(ap, trailing), False
else:
maximo_cierre = max(maximo_cierre, ci)
trailing = max(trailing, maximo_cierre * 0.92)
print(fecha, f"{salida:.2f}", f"{salida / entrada - 1:+.2%}",
i - indice["2023-11-01"])2024-03-08 122.71 +6.45% 92With 8%, the position exits on March 8, 2024, after 92 sessions, with +6.45% versus +9.93% with 5%. A wider trailing stop tolerates more noise before triggering: the position lasts more sessions, but exits further from the high. There is no “correct” percentage; choosing it by looking at the result on a single stretch of data is overfitting (level 2).
- Add to the orders
matcha case for market orders with a quantity that is not positive, and check that it rejects{"tipo": "mercado", "lado": "compra", "cantidad": 0}. Where does the new case have to go?
Solution
orden = {"tipo": "mercado", "lado": "compra", "cantidad": 0}
match orden:
case {"cantidad": q} if q <= 0:
print(f"REJECTED: quantity {q}")
case {"tipo": "mercado", "lado": lado, "cantidad": q}:
print(f"MARKET {lado} {q}")
case _:
print("REJECTED")REJECTED: quantity 0It has to go before the market case: if it came after, the order would first match {"tipo": "mercado", …} and
would be sent with quantity 0. Since it does not name the type, the new case also protects limit and stop orders.
Each answer is folded under its question
Self-assessment
With
cierre = 101andapertura = 100, anif cierre > aperturaprints “bullish”, the followingelif cierre > 100prints “above 100” and theelse, “other”. What is printed?- A
bullishandabove 100 - B
above 100 - C
other - D
bullish
Show answer
Answer D.
bullishIn an
if/elif/elsechain only the first branch whose condition holds runs. Even thoughcierre > 100is also true, theelifis not even evaluated.- A
In a
forloop that looks for the day the low touches the stop, when does the loop'selseblock run?- AAlways, when the loop finishes
- BWhen the loop ends without going through
break: the stop was not touched on any day - CWhen
breakruns - DNever:
fordoes not acceptelse
Show answer
Answer B. When the loop ends without going through
break: the stop was not touched on any dayA loop's
elseruns if the loop goes through every element without abreak. It is the way to say “not found” without a flag variable.A bar has its low below the stop and its high above the target. With daily data, what does a prudent backtest assume?
- AThat the stop was hit first
- BThat the target was hit first
- CThat neither was hit
- DThat both were executed
Show answer
Answer A. That the stop was hit first
A daily bar does not tell you what happened first within the day. Assuming the target would mean picking the most favorable outcome; the conservative assumption is the stop.
What does
case {"tipo": "limite", "precio": p} if p > 0:do with the order{"tipo": "limite", "lado": "compra", "precio": 101.2}?- AIt does not match, because the order has an extra key (
lado) - BIt raises a syntax error
- CIt matches and binds
p = 101.2 - DIt matches only if
preciois anint
Show answer
Answer C. It matches and binds
p = 101.2A mapping pattern requires the keys it names and allows extra keys.
pcaptures the value ofprecioand the guardif p > 0holds.- AIt does not match, because the order has an extra key (
Key points
if/elif/elseruns only the first branch whose condition holds. Indentation (4 spaces) marks which code belongs to each branch.foriterates over a collection;enumerateadds the index.breakexits the loop and the loop'selseruns only if there was nobreak.whilerepeats as long as a condition holds: for things of unknown duration, such as a position open with a trailing stop. Make sure the condition eventually becomes false.- With daily bars you cannot tell whether the stop or the target was hit first: a prudent backtest assumes the stop.
match-casecompares the shape of a value: with mapping patterns and guards, it validates and dispatches market, limit and stop orders in a single block.