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.

Fig. — 5% trailing stop on SINT: entry on 1 November 2023 and exit on 23 February 2024
04/06
Module
0.3 · Language fundamentals
Duration
50 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

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.

IN [01]python
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])
OUT [01]SINT bars as a list of tuples
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:

IN [02]python
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}")
OUT [02]Classifying a bar
2025-12-31 bullish body 2.21 / range 3.73

Four 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”) and else are optional; there can be as many elif as needed and a single else, at the end.
  • rango == 0 handles a bar with no range (a day without trading) separately: it is classified as a doji with no further calculation. or stops evaluating as soon as it finds something true.

When the decision only chooses between two values, the conditional expression does it in one line:

IN [03]python
lado = "largo"   # long; "corto" is short
signo = 1 if lado == "largo" else -1
pnl = signo * (cierre - 139.98) * 100
print(signo, f"{pnl:+.2f}")
OUT [03]Conditional expression
1 +224.00

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

IN [04]python
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()))
OUT [04]Counting bars of each type in 2025
{'bullish': 124, 'bearish': 109, 'doji': 28} 261

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

yesnoyesnoNext barLow ≤ stop?Exit at the stopHigh ≥ target?Exit at target
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 --> B

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

IN [05]python
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)
OUT [05]First session that touches the stop or the target
Entry 142.51  stop 138.23  target 151.06
2025-05-07: stop hit (low 134.69)
Sessions in position: 3

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

IN [06]python
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")
OUT [06]5% trailing stop from November 2023
Entry 115.27 on 2023-11-01
Exit 126.71 on 2024-02-23, highest close 133.38
Result +9.93% in 82 sessions

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

IN [07]python
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}")
OUT [07]Validating orders with patterns
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 > 0 after 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 the else of match. 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

  1. 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
IN [08]python
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%}")
OUT [08]Rises of more than 2% in 2024
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.

  1. 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
IN [09]python
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"])
OUT [09]8% trailing stop
2024-03-08 122.71 +6.45% 92

With 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).

  1. Add to the orders match a 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
IN [10]python
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")
OUT [10]Rejecting non-positive quantities
REJECTED: quantity 0

It 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

  1. With cierre = 101 and apertura = 100, an if cierre > apertura prints “bullish”, the following elif cierre > 100 prints “above 100” and the else, “other”. What is printed?

    • Abullish and above 100
    • Babove 100
    • Cother
    • Dbullish
    Show answer

    Answer D. bullish

    In an if/elif/else chain only the first branch whose condition holds runs. Even though cierre > 100 is also true, the elif is not even evaluated.

  2. In a for loop that looks for the day the low touches the stop, when does the loop's else block run?

    • AAlways, when the loop finishes
    • BWhen the loop ends without going through break: the stop was not touched on any day
    • CWhen break runs
    • DNever: for does not accept else
    Show answer

    Answer B. When the loop ends without going through break: the stop was not touched on any day

    A loop's else runs if the loop goes through every element without a break. It is the way to say “not found” without a flag variable.

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

  4. 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 precio is an int
    Show answer

    Answer C. It matches and binds p = 101.2

    A mapping pattern requires the keys it names and allows extra keys. p captures the value of precio and the guard if p > 0 holds.

Key points

  • if/elif/else runs only the first branch whose condition holds. Indentation (4 spaces) marks which code belongs to each branch.
  • for iterates over a collection; enumerate adds the index. break exits the loop and the loop's else runs only if there was no break.
  • while repeats 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-case compares the shape of a value: with mapping patterns and guards, it validates and dispatches market, limit and stop orders in a single block.

Resources