Quant500 Academy

1. How to store a time series without regretting it

Level 5: Storing, updating and scaling

You arrive here with the data already downloaded and already validated. What remains is the decision that looks administrative and is not: where you put it and in what shape. A badly built store almost never throws errors, which would be the convenient outcome. What it does is charge you forty seconds every time you want to test an idea, until you stop testing ideas.

CSV is convenient and that is why it gets expensive

CSV has one enormous virtue: anything opens it, you can inspect it with your eyes and it forces you to install nothing. That is why everybody starts there and they are right to. It has three defects that grow with the project:

Put numbers on it. Five hundred stocks, twenty-five years, some 252 sessions a year: around 3.1 million OHLCV rows. As a stated order of magnitude, and not as a promise, that is hundreds of megabytes in CSV and tens in a compressed columnar format. The exact ratio depends on your columns; measure it on your own machine before quoting it.

What a compressed columnar format buys you

A columnar file stores each column separately, with its type in the schema and its own compression. Out of that come four advantages that are not cosmetic:

The price to pay is real: you no longer open it with a text editor, you do not fix a cell by hand and adding a row means rewriting a block rather than appending a line at the end. It is a store, not a notebook.

import pandas as pd

# The table arrives in long format: one row per (simbolo, fecha)
precios = pd.read_csv("precios_crudos.csv", parse_dates=["fecha"],
                      keep_default_na=False, na_values=[""])

# Explicit types: whatever you do not declare, parquet freezes as pandas guessed it
precios = precios.astype({
    "simbolo": "string",
    "cierre": "float64",   # float32 takes up half the space and loses precision when accumulating
    "volumen": "int64",
})

# The partition column is derived from the data, never invented
precios["anio"] = precios["fecha"].dt.year

precios.to_parquet(
    "almacen/precios",
    engine="pyarrow",
    compression="zstd",
    partition_cols=["anio"],
    index=False,
)

Long or wide: the same table with two different costs

In long format there is one row per symbol-and-date combination. The schema never changes: adding stock number five hundred and one means new rows, not new columns. It filters well, it partitions well and it does not force you to fill gaps with missing values for stocks that were not trading yet.

In wide format the index is the date and there is one column per symbol. It is what matrix operations want: returns, correlations, betas, anything that treats the five hundred stocks as a vector. Its problem is that the schema depends on the data, and that with the five OHLCV columns you land on 3,000 columns or on five parallel tables that have to be kept in sync.

The practical rule is simple: store in long format and pivot to wide in memory just before the calculation that needs it. The pivot is cheap compared with what it avoids. A matrix of 6,300 sessions by 500 columns is 3.15 million cells, some 25 MB in eight-byte floating point: it fits with room to spare.

ancho = precios.pivot(index="fecha", columns="simbolo", values="cierre")
retornos = ancho.pct_change()
matriz = retornos.corr()          # 500 x 500, computed on aligned data

Partitioning is writing an index with folders

Partitioning means splitting the store into subfolders according to the value of a column, so that the reader opens only the ones it needs. The choice is not a matter of style, it answers the question of which queries you run most often:

You can nest the two, but mind the arithmetic. Symbol and then year is 500 by 25, that is 12,500 folders with around 250 rows each: files of a few kilobytes in which the metadata weighs more than the data, and thousands of file opens that you feel. Aim for files of tens to hundreds of megabytes; many tiny files perform worse than one big one.

cierres = pd.read_parquet(
    "almacen/precios",
    columns=["simbolo", "fecha", "cierre", "anio"],
    filters=[("anio", ">=", 2023), ("anio", "<=", 2024)],
)

# Partition columns come back as categoricals: comparing with > fails
cierres["anio"] = cierres["anio"].astype("int16")

A case: the same data, two stores

Take the 3.1 million rows from earlier. Stored in a single CSV, any question starts by parsing the entire file: tens of seconds on a normal laptop, as an order of magnitude, with memory usage several times the file size while the types are being built.

Stored in columnar format partitioned by year, the average question about 2024 volume opens one folder out of twenty-five and decodes two columns out of six. The real work drops below one percent of the previous figure, and the read goes from tens of seconds to a fraction of a second. It is not magic in the format: it is that you have stopped reading 99% of the store to answer something that only needed 1%.

The rest of Quant500

What you are learning, applied to the index every day:

The model portfolio, weekly in your inbox

Every Saturday, its week in one minute: how it went, the best and the worst, and which insiders bought with their own money. Plus, the day the model is rebuilt, which stocks come in and which go out. A simulation, not advice. At most one email a week, no ads, no account; one-click unsubscribe. Made by one person: Pol Gracia.

A model portfolio with simulated results, not a personalized recommendation. Your email is used for this only: privacy.

Behind the Academy and the rest of Quant500 there is one person: Pol Gracia Serrahima.