---
title: "0.3.2 Strings, f-strings and formatting numbers and dates"
description: "String methods to clean symbols, f-strings for trade reports and ISO 8601 dates, with Spanish number formatting that does not depend on the locale."
url: "https://pythonparatrading.com/en/python-trading-course/level-0/python-strings-f-strings-formatting/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-0/python-strings-f-strings-formatting.png"
---

# 0.3.2 · Strings, f-strings and formatting numbers and dates

> A ticker, a date and a line of a report are text. The lesson teaches how to split symbols such as “ES=F” or “EURUSD”, how to format prices, percentages and P&L with f-strings and how to write and read dates without ambiguity.

- Course: [Level 0: Python from scratch and a data mindset](https://pythonparatrading.com/en/python-trading-course/level-0/)
- Module: 0.3 · Language fundamentals
- Duration: 45 min
- Prerequisites: [0.3.1 · Variables, types and operators; floating point and decimal for prices and ticks](https://pythonparatrading.com/en/python-trading-course/level-0/python-variables-types-and-decimal/)

## By the end of this lesson

- Extract parts of a symbol (`ES=F`, `EURUSD`, `BTC-USD`) with indexes, slices and string methods such as `partition`, `split` and `removesuffix`.
- Format prices, percentages and P&L with f-strings: decimals, thousands separator, sign, width and alignment.
- Write numbers in Spanish format (`1.234,56`) without depending on the system `locale`.
- Convert dates between text and `date` with ISO 8601, `strftime` and `strptime`, and explain why ISO 8601 is the format for data.

## Strings: the text of a trading program

Tickers, market names, order sides, lines of a file and messages in a report: all of that is text, and in Python text
is the `str` type (lesson 0.3.1). A **string** is written between single or double quotes, which are equivalent; triple
quotes (`"""…"""`) allow several lines.

A string is a sequence of characters in order, and each character has a position, its **index**, which starts at 0.
With square brackets you read one character (`s[0]`) or a piece (`s[inicio:fin]`, the **slice**, which includes
`inicio` and excludes `fin`). Negative indexes count from the end: `s[-1]` is the last character.

```python
par = "EURUSD"
print(len(par), par[0], par[-1])
print(par[:3], par[3:])
print(par[::-1])
print("USD" in par, "JPY" in par)
```
```output
6 E D
EUR USD
DSURUE
True False
```

`len` gives the number of characters. `[:3]` takes the first three (from 0 to 2) and `[3:]`, from index 3 to the end:
the two currencies of the pair. A third number in the slice is the step; `[::-1]` walks the string backwards. The `in`
operator asks whether a piece appears inside the string and returns a `bool`.

Strings are **immutable**: you cannot change a character (`par[0] = "G"` raises an error). All the operations that
seem to modify them return a new string. With `+` they are concatenated and with `*` repeated:

```python
base, cotizada = "EUR", "USD"
simbolo = base + "/" + cotizada
print(simbolo)
print("-" * 20)
```
```output
EUR/USD
--------------------
```

The first line assigns two variables at once; the form is explained with tuples in lesson 0.3.3.

## Methods to clean and split symbols

Symbols arrive written in many ways: in lowercase from a form, with spaces from a spreadsheet, with suffixes that each
data provider uses in its own way. In Yahoo Finance, for example, `ES=F` is the E-mini S&P 500 future (the contract
nearest to expiry) and `EURUSD=X`, the euro-dollar rate; at other providers the same pair is `EURUSD` or `EUR/USD`, and
a cryptocurrency can be `BTC-USD` or `BTCUSDT`[^1].

String **methods** are functions called with a dot after the value (`simbolo.upper()`). The ones used most for this
job:

| Method | What it does | Example | Result |
|---|---|---|---|
| `strip()` | Removes spaces at the start and end | `" es=f ".strip()` | `"es=f"` |
| `upper()` / `lower()` | Uppercase / lowercase | `"es=f".upper()` | `"ES=F"` |
| `partition(sep)` | Splits in three at the first occurrence of `sep` | `"ES=F".partition("=")` | `("ES", "=", "F")` |
| `split(sep)` | Splits at every occurrence of `sep` | `"BTC-USD".split("-")` | `["BTC", "USD"]` |
| `replace(a, b)` | Replaces `a` with `b` | `"EUR/USD".replace("/", "")` | `"EURUSD"` |
| `startswith` / `endswith` | Does it start or end with…? | `"ES=F".endswith("=F")` | `True` |
| `removesuffix` / `removeprefix` | Removes a suffix or prefix if present | `"EURUSD=X".removesuffix("=X")` | `"EURUSD"` |

With them, a badly written symbol is normalized in one line, and a well-written symbol is broken down into its parts:

```python
entrada = "  es=f \n"
simbolo = entrada.strip().upper()
print(repr(entrada), "->", repr(simbolo))

raiz, sep, tipo = simbolo.partition("=")
print(raiz, tipo, simbolo.endswith("=F"))

base, cotizada = "BTC-USD".split("-")
print(base, cotizada)

print("EURUSD=X".removesuffix("=X"))
print("EUR/USD".replace("/", ""))
```
```output
'  es=f \n' -> 'ES=F'
ES F True
BTC USD
EURUSD
EURUSD
```

`repr` shows the string as it is, with quotes and with the line break written as `\n`; `print` would show it without
them and the spaces would go unnoticed. It is the way to see what is really inside a text that does not behave as
expected.

`partition` always returns three pieces, even if it does not find the separator (the last two are empty); `split`
returns as many as there are, and assigning them to two variables only works if there are exactly two. That is why,
when a symbol has a single marker (`=` in Yahoo), `partition` is the robust option.

<aside class="callout" data-kind="aviso">

Normalize symbols before comparing them. `"es=f" == "ES=F"` is `False`: to Python they are different texts. A symbol
with a trailing space is not found in the list of positions, the order is not canceled and nobody notices until the
statement arrives.

</aside>

Splitting follows the same pattern for any provider: clean, check the format and separate the parts.

```mermaid
flowchart TB
  E[Received text] --> N[strip and upper]
  N --> F{Contains =?}
  F -- yes --> P[partition]
  F -- no --> C[Slice by position]
```

When the format has no separator, as in `EURUSD`, it is split by position: currencies always have three letters (they
are ISO 4217 codes), so `[:3]` and `[3:]` are enough. If there is neither a separator nor a fixed length (`BTCUSDT`),
you need a table with the symbols published by the market itself; guessing the parts is a sure source of errors.

## f-strings: text with values inside

An **f-string** is a string with an `f` in front. Inside, everything between braces is evaluated as code and replaced
by its value. It is the way to build messages, file names and report lines:

```python
ticker = "SINT"
cierre = 142.22
anterior = 139.98
print(f"{ticker} closes at {cierre}")
print(f"Change: {cierre - anterior}")
```
```output
SINT closes at 142.22
Change: 2.240000000000009
```

Any expression can go inside the braces, such as the subtraction on the second line, which shows once more the floating
point of lesson 0.3.1: the text needs a format that fixes the decimals. If you forget the `f`, the braces appear as
they are in the text and no error warns you. A useful shortcut: an `=` at the end of the expression also writes its
name.

```python
print(f"{cierre=}  {anterior=}")
print(f"{cierre - anterior = :.2f}")
```
```output
cierre=142.22  anterior=139.98
cierre - anterior = 2.24
```

`{cierre=}` writes the variable's name, an equals sign and its value: useful for following a calculation without
writing the name twice. The second line adds a format after the colon, `.2f`, which is what comes next.

## The format mini-language

What goes after the colon inside the braces is the **format specification**. Its fields come in a fixed order, and each
one is optional. The header figure breaks down `{pnl:>+12,.2f}`, the typical format of a P&L column:

| Field | Example | What it does |
|---|---|---|
| Alignment | `<` `>` `^` | Left, right, centered (with a fill character in front, if wanted: `*>`) |
| Sign | `+` | Writes the sign on positive numbers too (by default, only on negative ones) |
| Width | `12` | Takes up at least 12 characters, padding with spaces |
| Grouping | `,` or `_` | Separates thousands with a comma or an underscore |
| Precision | `.2` | Number of decimals (with `f` and `%`) |
| Type | `f` `%` `e` `d` | Fixed-point decimal, percentage (multiplies by 100), scientific notation, integer |

```python
pnl = 1426.71
rent = 0.07041
volumen = 2_322_609

print(f"{cierre:.2f}  {cierre:.4f}  {cierre:.0f}")
print(f"{rent:.2%}  {rent:+.1%}  {-rent:+.1%}")
print(f"{volumen:,}  {volumen:_}  {volumen:.2e}")
print(f"{pnl:+,.2f}  {-pnl:+,.2f}")
print(f"[{pnl:>12,.2f}]  [{pnl:<12,.2f}]  [{'SINT':^10}]")
```
```output
142.22  142.2200  142
7.04%  +7.0%  -7.0%
2,322,609  2_322_609  2.32e+06
+1,426.71  -1,426.71
[    1,426.71]  [1,426.71    ]  [   SINT   ]
```

Three details worth settling. The `%` type multiplies by 100: the return is stored as a fraction of one (0.07041) and
displayed as a percentage, never the other way around. The `.2f` format rounds **only what is displayed**: the variable
keeps the same value, with all its decimals. And the format's rounding is the floating-point one, with the same
misleading ties as `round()` in lesson 0.3.1; for amounts that have to reconcile to the cent, the rounding is done
beforehand with `Decimal`.

The explicit sign (`+`) is the convention of P&L reports and price changes: `+1,426.71` and `-1,426.71` can be told
apart at a glance, and a column of signed figures that always has the same width is easier to read.

## An aligned trade report

Width and alignment are used to build text tables: the log of a script that trades, an email with the day's summary or
the output of a backtest in the terminal. Numbers are right-aligned, so that the units line up one below the other, and
text is left-aligned.

The five trades in the cell are the first ones in `operaciones_sint.csv` (link in “Resources”), with the P&L already net
of commissions. The list of tuples and the `for` loop are explained in lessons 0.3.3 and 0.3.4; here it is enough to
know that the line inside the loop is repeated for each trade:

```python
operaciones = [
    ("2021-03-04", "ORO", "corto", 200, 190.08, -3123.56),
    ("2021-09-02", "SINT", "largo", 180, 108.64, 7.43),
    ("2021-10-27", "SINT", "corto", 10, 112.07, -31.50),
    ("2021-11-10", "SINT", "corto", 40, 104.30, -306.72),
    ("2022-07-22", "SINT", "largo", 200, 102.83, 1426.71),
]
cabecera = (f"{'Date':<10}  {'Symbol':<7}  {'Side':<5}  "
            f"{'Qty':>5}  {'Entry':>8}  {'P&L':>10}")
print(cabecera)
print("-" * len(cabecera))
for fecha, simbolo, lado, cant, entrada, pnl in operaciones:
    print(f"{fecha:<10}  {simbolo:<7}  {lado:<5}  "
          f"{cant:>5}  {entrada:>8.2f}  {pnl:>+10,.2f}")
```
```output
Date        Symbol   Side     Qty     Entry         P&L
-------------------------------------------------------
2021-03-04  ORO      corto    200    190.08   -3,123.56
2021-09-02  SINT     largo    180    108.64       +7.43
2021-10-27  SINT     corto     10    112.07      -31.50
2021-11-10  SINT     corto     40    104.30     -306.72
2022-07-22  SINT     largo    200    102.83   +1,426.71
```

The header uses the same widths as the rows, and the dashed line is as long as the header thanks to `len`. Two
f-strings in a row inside parentheses are joined into a single string: it is the way to split a long line without the
code going over 72 characters. The strings inside the braces (`{'Date':<10}`) use single quotes to stand apart from the
f-string's double quotes; since Python 3.12 the double quotes could be repeated, but it reads worse.

## Spanish format without touching the locale

Python formats numbers the English way: comma for thousands and point for decimals. In Spain it is written the other
way around, `1.426,71`, and the percent sign is separated from the figure (`7,04 %`). For a report that a person reads,
the local format matters: `1,426` is one thousand four hundred twenty-six for an English reader and one point four for
a Spanish one.

Python has an “official” way to do it, the `locale` module and the `n` format type, which use the system's regional
settings. It has two problems. The result depends on the machine: the same script writes `1.426,71` on a laptop set to
Spanish and `1426.71` on a server with the `C` locale, the usual one in containers. And `locale.setlocale` changes a
global setting of the process, which affects everything that runs in it, libraries included.

The robust alternative is to format the English way, which is deterministic, and then swap the two signs.
`str.maketrans(",.", ".,")` creates a translation table (each comma for a point and each point for a comma) and
`translate` applies it in a single pass. The cell wraps it in a function so as not to repeat it; functions are covered
in lesson 0.3.5, and here it is enough to know that `def` gives a name to a calculation and `return` returns its result:

```python
A_ESPANOL = str.maketrans(",.", ".,")

def es(numero, decimales=2):
    """Number with points for thousands and a comma for decimals."""
    return f"{numero:,.{decimales}f}".translate(A_ESPANOL)

def es_pct(tanto_por_uno, decimales=2):
    """Percentage in Spanish format: 7,04 %."""
    return es(tanto_por_uno * 100, decimales) + " %"

print(es(1426.71), es(-3123.56), es(2_322_609, 0))
print(es_pct(0.07041), es_pct(-0.25, 1))
print(f"Day's P&L: {es(pnl)} €")
```
```output
1.426,71 -3.123,56 2.322.609
7,04 % -25,0 %
Day's P&L: 1.426,71 €
```

The precision can also come from a variable: `{numero:,.{decimales}f}` nests a pair of braces inside the
specification. The result is the same on any machine, and the numbers are always stored as numbers: the Spanish format
is only for displaying them. A data file (CSV, Parquet) never contains formatted numbers, neither in Spanish nor in
English format.

<aside class="callout" data-kind="nota">

The reverse problem appears when reading: a CSV exported from a spreadsheet set to Spanish contains `1.426,71`, and
`float("1.426,71")` fails. It is converted by removing the points and changing the comma into a point
(`texto.replace(".", "").replace(",", ".")`) or, better, by telling the file reader: pandas has the parameters
`decimal=","` and `thousands="."` (module 0.5).

</aside>

## Dates: ISO 8601, strftime and strptime

A date written as text is ambiguous: `03/04/2025` is April 3 in Spain and March 4 in the United States. The **ISO
8601** standard solves the problem by writing from the largest unit to the smallest, `2025-04-03`, with only one
possible order. It is the format of the course's data files, of brokers' APIs and of pandas. It has one more advantage:
as text, ISO dates sort the same way as on the calendar.

```python
iso = ["2025-04-03", "2024-12-31", "2025-01-15"]
espanol = ["03/04/2025", "31/12/2024", "15/01/2025"]
print(sorted(iso))
print(sorted(espanol))
```
```output
['2024-12-31', '2025-01-15', '2025-04-03']
['03/04/2025', '15/01/2025', '31/12/2024']
```

`sorted` sorts alphabetically. With ISO 8601 the result is chronological; with the Spanish format, December 31, 2024
ends up last because “31” comes after “15” in alphabetical order.

To operate with dates (add days, find the day of the week) you do not use the text but the `date` type from the
`datetime` module. `date.fromisoformat` turns an ISO text into a date, and `isoformat` does the opposite. For any other
format, `strftime` (“string from time”) writes a date with a pattern, and `strptime` (“string parse time”) reads it:

| Code | Meaning | Example |
|---|---|---|
| `%Y` | Four-digit year | `2025` |
| `%m` | Two-digit month | `12` |
| `%d` | Two-digit day | `31` |
| `%H:%M:%S` | Hours, minutes and seconds | `15:30:00` |
| `%A` / `%B` | Name of the day / of the month (depends on the locale) | `Wednesday` / `December` |

```python
from datetime import date, datetime

fecha = date.fromisoformat("2025-12-31")
print(fecha, fecha.year, fecha.month, fecha.day)
print(fecha.strftime("%d/%m/%Y"), f"{fecha:%d/%m/%Y}")
print(fecha.weekday(), f"{fecha:%A}")

leida = datetime.strptime("31/12/2025", "%d/%m/%Y").date()
print(leida == fecha, leida.isoformat())

orden = datetime.fromisoformat("2025-12-31T15:30:00")
print(orden, orden.date(), orden.time())
```
```output
2025-12-31 2025 12 31
31/12/2025 31/12/2025
2 Wednesday
True 2025-12-31
2025-12-31 15:30:00 2025-12-31 15:30:00
```

f-strings accept the same codes after the colon: `{fecha:%d/%m/%Y}` is equivalent to `strftime`. `weekday` numbers the
days from Monday (0) to Sunday (6): December 31, 2025 is a Wednesday. `datetime` adds the time to the date; in ISO
8601, the `T` separates the two parts.

The names of days and months (`%A`, `%B`) have the same problem as the number format: they depend on the locale and,
on the course runner, as on many servers, they come out in English. The solution is the same: do not depend on it.

```python
DIAS = ("lunes", "martes", "miércoles", "jueves",
        "viernes", "sábado", "domingo")
MESES = ("enero", "febrero", "marzo", "abril", "mayo", "junio",
         "julio", "agosto", "septiembre", "octubre", "noviembre",
         "diciembre")

print(f"{DIAS[fecha.weekday()]}, {fecha.day} de "
      f"{MESES[fecha.month - 1]} de {fecha.year}")
print(f"Report_{fecha:%Y%m%d}.txt")
```
```output
miércoles, 31 de diciembre de 2025
Report_20251231.txt
```

`DIAS[fecha.weekday()]` uses the day's number as an index, just as with the characters of a string. The months have 1
subtracted because `month` goes from 1 to 12 and indexes start at 0. The last line shows another use of ISO 8601: file
names with the date as `YYYYMMDD` appear sorted in any folder.

<aside class="callout" data-kind="riesgo">

A date without a time and a time without a time zone are incomplete as soon as you trade in more than one market. For
most of the year, Wall Street opens at 15:30 Madrid time, which is 9:30 in New York; for a few weeks in March and
October, when one side of the Atlantic has already changed to or from daylight saving time and the other has not, it
opens at 14:30. Time zones and daylight saving changes are covered in lesson 0.4.4; until then, the course works with
session dates.

</aside>

## Exercises

1. Write the code that, starting from `"  usdjpy "`, gets the normalized symbol `USDJPY`, the base currency and the
   quote currency, and displays it as `USD/JPY`.

**Solution.**

```python
texto = "  usdjpy "
par = texto.strip().upper()
base, cotizada = par[:3], par[3:]
print(par, base, cotizada)
print(f"{base}/{cotizada}")
```
```output
USDJPY USD JPY
USD/JPY
```

`strip` removes the spaces, `upper` converts to uppercase and the slices `[:3]` and `[3:]` separate the two
currencies, which always have three letters.

2. With `pnl = -1288.04` and `rent = -0.10063`, write a line that looks exactly like this (width 12 for the P&L and 8
   for the return, right-aligned): `P&L:    -1,288.04 | Ret.:  -10.06%`.

**Solution.**

```python
pnl = -1288.04
rent = -0.10063
print(f"P&L: {pnl:>12,.2f} | Ret.: {rent:>8.2%}")
```
```output
P&L:    -1,288.04 | Ret.:  -10.06%
```

`>12` reserves twelve characters and aligns to the right; `,` separates the thousands and `.2f` keeps two decimals. In
the return, `.2%` multiplies by 100 and adds the symbol: “-10.06%” takes up seven characters, so width 8 leaves one
space in front.

3. A broker's file contains the date `"04/03/2025 09:30"` in US format (month/day/year). Convert it to ISO 8601 and
   write the day of the week in Spanish.

**Solution.**

```python
momento = datetime.strptime("04/03/2025 09:30", "%m/%d/%Y %H:%M")
print(momento.isoformat())
print(DIAS[momento.weekday()])
```
```output
2025-04-03T09:30:00
jueves
```

The `strptime` pattern has to describe the text as it arrives: `%m/%d` and not `%d/%m`. With the Spanish pattern, the
date would have been read as March 4 without any error: it is the most dangerous failure, because it gives no warning.

[^1]: Yahoo Finance: [ES=F](https://finance.yahoo.com/quote/ES=F/) and [EURUSD=X](https://finance.yahoo.com/quote/EURUSD=X/).
Each provider documents its symbols; when you switch providers, you have to translate them.

## Self-assessment

1. What does `"EURUSD"[3:]` return?

   - A. `"EUR"`
   - B. `"USD"`
   - C. `"RUSD"`
   - D. `"URUSD"`

   *Answer B: `"USD"`.* Indexes start at 0, and the slice `[3:]` goes from the character at index 3 to the end: `U`, `S`, `D`. `[:3]` would give `EUR`.

2. What does `f"{-1288.04:+,.2f}"` display?

   - A. `-1288.04`
   - B. `-1.288,04`
   - C. `-1,288.04`
   - D. `+1,288.04`

   *Answer C: `-1,288.04`.* `+` asks for the sign always (here the number is already negative), `,` groups thousands with commas and `.2f` keeps two decimals. This is the English format: the comma separates thousands and the point, decimals.

3. Why do data files store dates as `2025-12-31` and not as `31/12/2025`?

   - A. Because Python cannot read the Spanish format
   - B. Because ISO 8601 is not ambiguous and, as text, it sorts the same way as on the calendar
   - C. Because it takes up less space
   - D. Because pandas requires it

   *Answer B: Because ISO 8601 is not ambiguous and, as text, it sorts the same way as on the calendar.* `03/04/2025` is April 3 in Spain and March 4 in the United States; `2025-04-03` has only one reading. And since it goes from the largest unit (year) to the smallest (day), alphabetical order matches chronological order.

4. A script formats amounts with `f"{x:n}"` so that they come out in the system's format. What is the problem?

   - A. None: it is the recommended way
   - B. That `n` does not accept decimals
   - C. That the result depends on each machine's `locale`: the same script writes different reports on the laptop and on the server
   - D. That it is slower

   *Answer C: That the result depends on each machine's `locale`: the same script writes different reports on the laptop and on the server.* `n` uses the active regional settings, which change between machines and on many servers are `C` (no thousands separator). A report has to come out the same everywhere: better to fix the format in the code.

## Key points

- A string is an immutable sequence of characters: it is indexed from 0, sliced with `[inicio:fin]` and its methods return new strings.
- To split symbols, `partition` and `split` separate on a character and `removesuffix` removes a known suffix. Normalize with `strip` and `upper` before comparing.
- The format goes after the colon in the f-string: `{pnl:>+12,.2f}` is right-aligned, with sign, width 12, thousands with commas and two decimals.
- The Spanish format is obtained by swapping commas and points with `translate`, without touching the `locale`: that way the report comes out the same on any machine.
- Dates are stored in ISO 8601 (`2025-12-31`): they are not ambiguous and sort correctly as text. `strftime` and `strptime` convert between `date` and any other format.

## Resources

### Research

- [Python for Trading Course - Part 1: Market Data with OpenBB](https://pythonparatrading.com/en/openbb-free-market-data/) · Research-0014 · 10 min

### Documentation

- [Python: format specification mini-language (official documentation)](https://docs.python.org/3/library/string.html#format-specification-mini-language) · docs.python.org
- [Python: string methods (official documentation)](https://docs.python.org/3/library/stdtypes.html#string-methods) · docs.python.org
- [Python: strftime and strptime format codes (official documentation)](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes) · docs.python.org

### Data

- [Level 0 data: 40 fictional trades (CSV)](https://pythonparatrading.com/curso/datos/nivel-0/operaciones_sint.csv) · Download · operaciones_sint.csv

Previous lesson: [0.3.1 · Variables, types and operators; floating point and decimal for prices and ticks](https://pythonparatrading.com/en/python-trading-course/level-0/python-variables-types-and-decimal/)

Next lesson: [0.3.3 · Lists, tuples, dictionaries, sets and comprehensions](https://pythonparatrading.com/en/python-trading-course/level-0/python-lists-tuples-dicts-sets/)
