Lesson 0.3.2 · 45 min

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.

Fig. — Anatomy of a format specification: P&L with sign, thousands and two decimals in 12 characters
02/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

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.

IN [01]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)
OUT [01]Indexes and slices in a currency pair
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:

IN [02]python
base, cotizada = "EUR", "USD"
simbolo = base + "/" + cotizada
print(simbolo)
print("-" * 20)
OUT [02]Concatenating and repeating
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 BTCUSDT1Note 1 · Yahoo Finance: ES=F and EURUSD=X. Each provider documents its symbols; when you switch providers, you have to translate them..

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

MethodWhat it doesExampleResult
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 / endswithDoes it start or end with…?"ES=F".endswith("=F")True
removesuffix / removeprefixRemoves 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:

IN [03]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("/", ""))
OUT [03]Normalizing and splitting symbols
'  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.

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

yesnoReceived textstrip and upperContains =?partitionSlice by position
Mermaid source
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:

IN [04]python
ticker = "SINT"
cierre = 142.22
anterior = 139.98
print(f"{ticker} closes at {cierre}")
print(f"Change: {cierre - anterior}")
OUT [04]Values inside text
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.

IN [05]python
print(f"{cierre=}  {anterior=}")
print(f"{cierre - anterior = :.2f}")
OUT [05]The debugging shortcut
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:

FieldExampleWhat 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)
Width12Takes up at least 12 characters, padding with spaces
Grouping, or _Separates thousands with a comma or an underscore
Precision.2Number of decimals (with f and %)
Typef % e dFixed-point decimal, percentage (multiplies by 100), scientific notation, integer
IN [06]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}]")
OUT [06]Prices, percentages, signs and thousands
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:

IN [07]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}")
OUT [07]Report of five trades
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:

IN [08]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)} €")
OUT [08]Deterministic Spanish format
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.

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.

IN [09]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))
OUT [09]ISO 8601 sorts correctly as text
['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:

CodeMeaningExample
%YFour-digit year2025
%mTwo-digit month12
%dTwo-digit day31
%H:%M:%SHours, minutes and seconds15:30:00
%A / %BName of the day / of the month (depends on the locale)Wednesday / December
IN [10]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())
OUT [10]From text to date and from date to text
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.

IN [11]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")
OUT [11]Spanish names without the locale
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.

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
IN [12]python
texto = "  usdjpy "
par = texto.strip().upper()
base, cotizada = par[:3], par[3:]
print(par, base, cotizada)
print(f"{base}/{cotizada}")
OUT [12]Normalizing a currency pair
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.

  1. 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
IN [13]python
pnl = -1288.04
rent = -0.10063
print(f"P&L: {pnl:>12,.2f} | Ret.: {rent:>8.2%}")
OUT [13]Line with fixed widths
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.

  1. 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
IN [14]python
momento = datetime.strptime("04/03/2025 09:30", "%m/%d/%Y %H:%M")
print(momento.isoformat())
print(DIAS[momento.weekday()])
OUT [14]US date to ISO 8601
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.

Footnotes

  1. Yahoo Finance: ES=F and EURUSD=X. Each provider documents its symbols; when you switch providers, you have to translate them. ↩

Each answer is folded under its question

Self-assessment

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

    • A"EUR"
    • B"USD"
    • C"RUSD"
    • D"URUSD"
    Show answer

    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
    Show answer

    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?

    • ABecause Python cannot read the Spanish format
    • BBecause ISO 8601 is not ambiguous and, as text, it sorts the same way as on the calendar
    • CBecause it takes up less space
    • DBecause pandas requires it
    Show answer

    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?

    • ANone: it is the recommended way
    • BThat n does not accept decimals
    • CThat the result depends on each machine's locale: the same script writes different reports on the laptop and on the server
    • DThat it is slower
    Show answer

    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

Data