---
title: "1.6.3 Seasonality and calendar effects"
description: "Day of the week, month, turn of the month and intraday profile with t-statistics, bootstrap and multiple testing: why almost no calendar effect holds up."
url: "https://pythonparatrading.com/en/python-trading-course/level-1/seasonality-and-calendar-effects/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-1/seasonality-and-calendar-effects.png"
---

# 1.6.3 · Seasonality: day of the week, month, turn of the month and intraday sessions

> Calendar effects are the easiest way to find a pattern in the data and one of the easiest ways to fool yourself. This lesson measures the classic ones with t-statistics and bootstrap intervals, shows how many “significant” results come out by chance when many are tested at once, and isolates what is actually stable: the intraday profile of volatility and volume.

- Course: [Level 1: Financial data, markets and analysis](https://pythonparatrading.com/en/python-trading-course/level-1/)
- Module: 1.6 · Financial exploratory analysis
- Duration: 60 min
- Prerequisites: [0.5.3 · Selection, filtering, groupby, merge, pivot, resample and moving windows](https://pythonparatrading.com/en/python-trading-course/level-0/pandas-groupby-merge-resample-rolling/), [1.6.2 · Stylized facts: fat tails, clustering, autocorrelation and leverage](https://pythonparatrading.com/en/python-trading-course/level-1/stylized-facts-of-financial-markets/)

## By the end of this lesson

- Compute the mean return by day of the week, by month and at the turn of the month, with its t-statistic and its bootstrap interval.
- Design the test properly: compare one day with the rest, not with zero.
- Count how many significant effects to expect by chance and apply the Bonferroni, Holm and FDR corrections.
- Measure the intraday profile of return, volatility and volume with 5-minute bars in New York time.

## Patterns in the calendar

A **calendar effect** is a systematic difference in return or volatility tied to a date: the day of the week, the
month, the days around the turn of the month, the time of day, the days before a holiday. They are attractive because
they are easy to code and easy to explain. They are also the simplest way to find a pattern in the data that does not
exist: there are many possible calendars, each with little data, and it is enough to test a few of them for one to come
out “significant”.

This lesson always follows the same flow:

```mermaid
flowchart TB
  H[Prior hypothesis] --> D[Data and calendar]
  D --> T[t-test or bootstrap]
  T --> M[Multiple-testing correction]
  M --> F[Out of sample]
  F --> C[Costs and decision]
```

The data is ISX and FIX, the two daily indices of level 1 (2019-2025), the 57 stocks of the universe from lesson
1.4.4 and the NOVA 5-minute bars for 2024 and 2025. A warning first, as in lesson 1.6.2: this is synthetic data and its
generator does **not** include day-of-the-week, month or turn-of-the-month effects. Whatever comes out of those tests is
chance or a side effect of the model, and that is why they make a good test bench: we know the right answer. The
intraday profile of volatility and volume, on the other hand, is in the generator.

## Day of the week

The t-statistic of a mean is the mean divided by its standard error:

$$
t = \frac{\overline{r}}{s / \sqrt{n}}
$$

With a few hundred data points, |t| > 1.96 corresponds to p < 0.05 (two-tailed).

```python
import numpy as np
import pandas as pd
from scipy import stats

isx = pd.read_csv("etf_indice_diario.csv", parse_dates=["fecha"],
                  index_col="fecha")["indice"]
fix = pd.read_csv("fix_indice_diario.csv", parse_dates=["fecha"],
                  index_col="fecha")["indice"]
px = pd.DataFrame({"ISX": isx, "FIX": fix})
r = np.log(px).diff().dropna()
DIAS = ["Mon", "Tue", "Wed", "Thu", "Fri"]

def por_grupo(x, grupos):
    g = x.groupby(grupos)
    out = pd.DataFrame({"n": g.size(), "media_%": 100 * g.mean()})
    out["t"] = g.mean() / (g.std() / np.sqrt(g.size()))
    return out

tablas = {c: por_grupo(r[c], r.index.dayofweek) for c in r}
dia = pd.concat(tablas, axis=1)
dia.index = DIAS
dia.round(3)
```
```output
     ISX                 FIX
       n media_%      t    n media_%      t
Mon  329   0.040  0.527  329   0.082  1.212
Tue  364   0.032  0.414  364  -0.017 -0.231
Wed  360   0.101  1.505  360   0.173  2.669
Thu  353   0.044  0.645  353   0.005  0.080
Fri  353   0.037  0.595  353   0.047  0.725
```

All the means are small and almost every t is below 2 in absolute value. The exception is FIX on Wednesday: a mean of
0.17% and t = 2.67. Before celebrating, two calculations. There are ten tests (two indices times five days): with ten
independent tests at 5%, the probability that at least one comes out significant by chance is
1 − 0.95¹⁰ ≈ 40%. And the test is badly posed.

### Against the rest, not against zero

The t in the table tests “the Wednesday mean is zero”. But an index that rises 14% a year has a positive daily mean on
every day: the question for a calendar effect is whether Wednesday **differs from the other days**. With a Welch test
(means of two groups with different variances):

```python
def frente_al_resto(x, grupos):
    filas = {}
    for g in np.unique(grupos):
        dentro, fuera = x[grupos == g], x[grupos != g]
        t, p = stats.ttest_ind(dentro, fuera, equal_var=False)
        filas[g] = {"dif_%": 100 * (dentro.mean() - fuera.mean()),
                    "t": t, "p": p}
    return pd.DataFrame(filas).T

res = frente_al_resto(r["FIX"], r.index.dayofweek)
res.index = DIAS
res.round(3)
```
```output
     dif_%      t      p
Mon  0.030  0.393  0.695
Tue -0.094 -1.173  0.241
Wed  0.145  1.981  0.048
Thu -0.066 -0.890  0.374
Fri -0.013 -0.181  0.856
```

Against the rest of the days, FIX's Wednesday drops to t = 1.98 and p = 0.048: just under 0.05 and the only one of the
five. With the hypothesis properly posed the result weakens but does not disappear, so the question becomes a different
one: is a result like this to be expected among all the ones we have looked at? It is answered further down, with many
more tests.

## Month of the year: one data point per year

For the month effect, each observation is the return of a full month: with seven years there are seven Januaries,
seven Februaries… A bootstrap interval (resample the seven values with replacement and recompute the mean thousands of
times) shows how little they say:

```python
mens = np.log(px.resample("ME").last()).diff().dropna()["ISX"]
rng = np.random.default_rng(42)
filas = {}
for mes, x in mens.groupby(mens.index.month):
    ic = stats.bootstrap((x.to_numpy(),), np.mean, n_resamples=4000,
                         method="percentile", rng=rng)
    filas[mes] = {"n": len(x), "media_%": 100 * x.mean(),
                  "ic_bajo_%": 100 * ic.confidence_interval.low,
                  "ic_alto_%": 100 * ic.confidence_interval.high,
                  "t": x.mean() / (x.std() / np.sqrt(len(x)))}
meses = pd.DataFrame(filas).T
meses.round(2)
```
```output
      n  media_%  ic_bajo_%  ic_alto_%     t
1   6.0     2.84      -0.21       5.60  1.73
2   7.0     0.34      -0.87       1.71  0.46
3   7.0    -5.53     -18.92       6.68 -0.74
4   7.0     0.45      -4.68       5.86  0.15
5   7.0     5.19      -0.04      10.90  1.65
6   7.0     5.97       1.82      10.61  2.45
7   7.0     0.16      -4.76       4.91  0.06
8   7.0    -0.88      -3.68       2.03 -0.56
9   7.0    -0.44      -3.83       2.52 -0.26
10  7.0     2.90       0.69       5.40  2.21
11  7.0     1.01      -0.73       2.97  0.99
12  7.0     1.10      -0.84       3.06  1.02
```

*Fig. — ISX, 2019–2025: mean return of each calendar month with its 95% bootstrap interval (seven years, six Januaries).* Twelve squares with the mean return of each month and a vertical bar with its bootstrap interval. Means range from −5.5% in March to +6.0% in June. March's interval, highlighted, runs from −18.9% to +6.7% because of the 2020 crash; almost all the others include zero, except June and October, narrowly.

The means range from −5.5% in March to +6.0% in June and the intervals are huge: March's goes from −18.9% to +6.7%.
March is negative because of the 2020 crash, a single year. June and October come out with t above 2 and with intervals
that exclude zero, but with seven data points each and twelve months tested. In this data, moreover, we know why: the
generator places the big rallies and sell-offs on specific dates (March 2020, spring 2025), and that leaves a mark on
the months in which they fell. It is an effect of history, not of the calendar. Real data behaves the same way: most
“month effects” in an asset with ten or twenty years of history are the mark of a handful of events.

## The turn of the month

The turn-of-the-month effect says that returns concentrate on the last trading day of a month and the first days of the
next. A common definition: the last day and the first three.

```python
mes = r.index.to_period("M")
orden = pd.Series(1, index=r.index).groupby(mes).cumsum()
total = pd.Series(1, index=r.index).groupby(mes).transform("sum")
cambio = (orden <= 3) | (orden == total)
print(f"Turn-of-the-month sessions: {cambio.sum()} of {len(r)}")
for c in r:
    a, b = r.loc[cambio, c], r.loc[~cambio, c]
    t, p = stats.ttest_ind(a, b, equal_var=False)
    print(f"{c}: {100 * a.mean():.3f}% versus "
          f"{100 * b.mean():.3f}%  (t = {t:.2f}, p = {p:.3f})")
```
```output
Turn-of-the-month sessions: 336 of 1759
ISX: 0.096% versus 0.040%  (t = 0.78, p = 0.434)
FIX: 0.054% versus 0.059%  (t = -0.07, p = 0.947)
```

In ISX the turn-of-the-month sessions return more on average than the rest, but with t = 0.78; in FIX, practically the
same. Nothing. Look at the definition: “last day and first three” is one of many possible ones (two and two, one and
four…). Trying several and keeping the best is another way of multiplying the tests, even if it does not show in the
final code.

## Multiple testing: how many “significant” results chance gives

If there is no effect, the p-value of a well-designed test is uniformly distributed between 0 and 1: 5% of the tests come
out below 0.05 by pure chance. With the 57 stocks of the universe and five days of the week there are 285 tests:

```python
from statsmodels.stats.multitest import multipletests

uni = pd.read_parquet("universo_precios.parquet").pivot(
    index="fecha", columns="ticker", values="cierre")
ru = np.log(uni).diff()
filas = []
for tk in ru:
    s = ru[tk].dropna()
    dias = s.index.dayofweek
    for d in range(5):
        t, p = stats.ttest_ind(s[dias == d], s[dias != d],
                               equal_var=False)
        filas.append({"ticker": tk, "dia": DIAS[d], "t": t, "p": p})
pr = pd.DataFrame(filas)
print(f"Tests: {len(pr)}; expected with p < 0.05: "
      f"{0.05 * len(pr):.1f}; observed: {(pr['p'] < 0.05).sum()}")
for metodo in ["bonferroni", "holm", "fdr_bh"]:
    rechaza = multipletests(pr["p"], alpha=0.05, method=metodo)[0]
    print(f"{metodo:>10}: {rechaza.sum()} significant")
pr.sort_values("p").head(3).round(4)
```
```output
Tests: 285; expected with p < 0.05: 14.2; observed: 10
bonferroni: 0 significant
      holm: 0 significant
    fdr_bh: 0 significant
    ticker  dia       t       p
127   ZXLC  Wed  2.9605  0.0032
20    ZXBI  Mon  2.7957  0.0055
93    ZXHX  Thu -2.7190  0.0067
```

*Fig. — Histogram of the p-values of 285 day-of-week tests (57 stocks × 5 days, each day against the rest).* Twenty bars of width 0.05 between 0 and 1, with heights between 10 and 21 tests, around a line at 14.25 marking what is expected with no effect. The first bar, highlighted, with p < 0.05, has 10 tests: fewer than expected. The histogram is flat, the signature of no effect.

Ten tests below 0.05, fewer than the fourteen chance would give, and none survives a correction. The histogram of the
285 p-values is flat: the signature of nothing there. The three corrections answer different questions:

| Method | What it controls | Effective threshold here |
|---|---|---|
| Bonferroni | Probability of **any** false positive | p < 0.05 / 285 ≈ 0.00018 |
| Holm | The same, in a stepwise and somewhat less strict way | From 0.05 / 285 to 0.05 |
| Benjamini-Hochberg (FDR) | Expected proportion of false positives among the rejections | Depends on how many small p-values there are |

If FIX's Wednesdays were part of a search like this, their p = 0.048 would pass none of them. Lesson 2.1.8 comes back to
this with the “factor zoo”: the literature on calendar effects and anomalies has the same problem at the scale of
thousands of papers.

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

The badly posed test from the beginning misleads spectacularly on the universe. If each day's mean is compared with zero
instead of with the rest, a stock heading for bankruptcy (ZXIR, which loses 1% per session on average until it is
delisted) gives a “Monday effect” with t = −4.3 that survives even Bonferroni. It is not a Monday effect: it is a stock
that falls every day. Exercise 1 checks it.

</aside>

## Intraday sessions

The finest calendar is the clock. With NOVA's 5-minute bars (502 sessions in 2024 and 2025), in New York time, you can
compute for each time slot the mean return, its t, the volatility and the volume:

```python
b = pd.read_parquet("nova_barras_5min.parquet")
ny = b["ts"].dt.tz_convert("America/New_York")
b["franja"] = ny.dt.strftime("%H:%M")
b["r"] = np.log(b["cierre"] / b["apertura"])
g = b.groupby("franja")
perfil = pd.DataFrame({
    "n": g.size(), "media_pb": 1e4 * g["r"].mean(),
    "vol_pb": 1e4 * g["r"].std(),
    "volumen_miles": g["volumen"].mean() / 1e3})
perfil["t"] = perfil["media_pb"] / (perfil["vol_pb"]
                                    / np.sqrt(perfil["n"]))
print(f"Slots: {len(perfil)}; |t| > 1.96: "
      f"{(perfil['t'].abs() > 1.96).sum()}")
perfil.iloc[[0, 1, 2, 30, 31, 76, 77]].round(1)
```
```output
Slots: 78; |t| > 1.96: 2
          n  media_pb  vol_pb  volumen_miles    t
franja
09:30   502       2.1    47.8          351.6  1.0
09:35   502       2.1    42.2          282.4  1.1
09:40   502      -0.1    36.3          236.8 -0.0
12:00   502      -0.4    14.9          115.5 -0.6
12:05   502      -0.1    15.8          118.3 -0.2
15:50   496       0.5    29.2          205.2  0.4
15:55   496       2.2    33.2         1277.8  1.5
```

The figure at the top of the page draws the two columns that matter. The volatility of the first bar (09:30) is about
three times that of midday and the last bar's rises again; volume traces the same U and shoots up in the 15:55 bar,
because it includes the closing auction (lesson 1.1.2). The slots from 13:00 onward have 496 sessions instead of 502: on
the six half-day sessions of the period the market closes at that time.

The mean returns, on the other hand, are noise: 2 slots of 78 with |t| > 1.96, when chance would give about 4.

```python
perfil.loc[perfil["t"].abs() > 1.96, ["n", "media_pb", "t"]].round(2)
```
```output
          n  media_pb     t
franja
13:40   496      1.76  2.43
15:35   496     -2.56 -2.07
```

*Fig. — NOVA, 2024–2025: t statistic of the mean return of each 5-minute bar; the band marks |t| < 1.96.* 78 bars of t statistics between −2.1 and +2.4 around zero, almost all inside the shaded ±1.96 band. Only two leave it, highlighted: 13:40, with t = 2.43, and 15:35, with t = −2.07. By chance about four would be expected.

A rule like “buy at 13:40 and sell at 13:45” comes from looking at 78 slots and keeping the best one. With a mean of 1.8
basis points in 5 minutes and a one-cent bid-ask spread on a stock of about 50 USD (2 basis points round trip on the
spread alone), it would not even be profitable if it were real.

What is useful in the intraday profile is its shape. What share of a day's variance is concentrated in each segment?

```python
def tramo(h):
    if h < "10:00":
        return "1 open 9:30-10:00"
    if h >= "15:30":
        return "3 close 15:30-16:00"
    return "2 rest 10:00-15:30"

completas = b.groupby(ny.dt.date)["franja"].transform("size") == 78
bc = b[completas]
var = bc.groupby(bc["franja"].map(tramo))["r"].apply(
    lambda x: (x ** 2).sum())
reparto = pd.DataFrame({"barras": bc.groupby(
    bc["franja"].map(tramo)).size() / completas.sum() * 78,
    "varianza_%": 100 * var / var.sum()})
reparto.round(1)
```
```output
                     barras  varianza_%
franja
1 open 9:30-10:00       6.0        23.9
2 rest 10:00-15:30     66.0        61.1
3 close 15:30-16:00     6.0        15.0
```

The six bars of the first half hour and the six of the last (15% of the session's time) account for 38.9% of the day's
variance: 23.9% the open and 15.0% the close. For a system this has three direct uses: splitting a large order along the
volume profile (as a VWAP algorithm does, lesson 1.7.1), placing wider stops at the open than at midday, and normalizing
any intraday signal by the typical volatility of its slot before comparing it with other times of day.

## How not to fool yourself with the calendar

- **Hypothesis before data.** Write down which effect you are going to test, with which definition and over which
  period, before looking. Lesson 2.2.4 proposes a research journal for that.
- **The right test.** One day against the rest, one month against the rest; not against zero.
- **Count the tests.** The ones you ran and the ones you discarded in your head (other definitions, other assets, other
  periods). Correct for all of them.
- **Out of sample and with costs.** An effect that survives in a period you did not use to find it and that pays the
  bid-ask spread and the commissions is a different matter. The site's research on intraday seasonality in futures
  follows that path all the way to the backtest and its statistics.

## Exercises

1. Repeat the 285 tests on the universe comparing each day's mean with **zero** (`stats.ttest_1samp`) instead of with
   the rest of the days. How many come out with p < 0.05 and how many survive Bonferroni? Which stocks are they?

**Solution.**

```python
filas = []
for tk in ru:
    s = ru[tk].dropna()
    for d in range(5):
        x = s[s.index.dayofweek == d]
        t, p = stats.ttest_1samp(x, 0.0)
        filas.append({"ticker": tk, "dia": DIAS[d], "t": t, "p": p})
p0 = pd.DataFrame(filas)
bonf = multipletests(p0["p"], alpha=0.05, method="bonferroni")[0]
print(f"p < 0.05: {(p0['p'] < 0.05).sum()};"
      f" after Bonferroni: {bonf.sum()}")
p0[bonf].round(5)
```
```output
p < 0.05: 23; after Bonferroni: 1
    ticker  dia        t        p
100   ZXIR  Mon -4.27665  0.00006
```

Quite a few more come out than with the right test, because stocks with a strong trend (up or down) give means other
than zero on every day. The one that survives Bonferroni is ZXIR, the stock that goes bankrupt in 2016, with a very
negative Monday: it reflects its continuous fall, not a Monday effect.

2. Compute NOVA's volatility by slot using only 2024 and only 2025, and the correlation between the two profiles. Is the
   shape stable?

**Solution.**

```python
ano = ny.dt.year
v24 = b[ano == 2024].groupby("franja")["r"].std()
v25 = b[ano == 2025].groupby("franja")["r"].std()
print(f"Correlation of the profiles: {v24.corr(v25):.3f}")
print(f"Open/midday 2024: {v24['09:30'] / v24['12:00']:.2f}")
print(f"Open/midday 2025: {v25['09:30'] / v25['12:00']:.2f}")
```
```output
Correlation of the profiles: 0.974
Open/midday 2024: 3.12
Open/midday 2025: 3.26
```

The shape repeats almost identically from one year to the next: the correlation between the two profiles is very high
and the ratio between the open and midday holds. That is the difference with the mean returns by slot, which change sign
from one year to the next: the shape of volatility is a property of the market; the mean of a slot, a sample.

3. An article claims that “the market goes up in December” and backs it with 30 years of data: 21 positive Decembers out
   of 30. What else do you need to take it seriously?

**Solution.**

A comparison with the other months (if the market goes up in 65% of months, 21 of 30 is nothing special), the size and
variability of the difference (a t or a bootstrap interval), how many months and definitions were tried before choosing
December, whether the effect shows up in a period other than the one used to discover it and in other markets, and
whether it pays the costs of getting in and out. And a plausible economic explanation (year-end flows, taxation) that
can be tested separately.

## Self-assessment

1. FIX's mean return on Wednesdays has a t-statistic of 2.67. What do you conclude?

   - A. That FIX has a Wednesday effect and you should buy on Tuesdays at the close
   - B. That Wednesday is the most volatile day
   - C. That the data has an error
   - D. Nothing yet: it is one of ten tests (two indices times five days), with one or two significant results expected by chance, and in this data the generator has no day-of-the-week effect

   *Answer D: Nothing yet: it is one of ten tests (two indices times five days), with one or two significant results expected by chance, and in this data the generator has no day-of-the-week effect.* With ten tests at 5%, the probability that at least one comes out significant by chance is about 40%. An effect like this has to be tested against a prior hypothesis, with a multiple-testing correction and out of sample.

2. You test the day-of-the-week effect on 57 stocks: 285 tests. How many do you expect with p < 0.05 if there is no effect at all?

   - A. None
   - B. About 57
   - C. About 14
   - D. About 285

   *Answer C: About 14.* 5% of 285 is 14.25. The lesson gets 10: fewer than chance would give, and none survives Bonferroni, Holm or FDR.

3. A stock heading for bankruptcy falls 1% a day on average. If you test “the mean Monday return is zero”, what happens?

   - A. Monday comes out significantly negative even though there is no Monday effect: the test picks up the stock's trend, not the day
   - B. Nothing comes out
   - C. Friday comes out significant
   - D. The test is correct

   *Answer A: Monday comes out significantly negative even though there is no Monday effect: the test picks up the stock's trend, not the day.* The right null hypothesis for a calendar effect is “Monday is no different from the other days”. Comparing with zero confuses the asset's drift with the day of the week.

4. Which intraday pattern of NOVA is stable and useful for a system?

   - A. That the 13:40 bar goes up
   - B. That volatility and volume follow a U: high at the open and the close, low at midday
   - C. That Friday afternoon goes down
   - D. None

   *Answer B: That volatility and volume follow a U: high at the open and the close, low at midday.* The mean returns by time slot are noise (2 of 78 slots significant, fewer than chance would give). The shape of volatility and volume, on the other hand, is large and persistent: it is useful for spreading execution, setting stops and normalizing signals.

5. Why is ISX's mean March return (−5.5%) not a seasonal effect?

   - A. Because with seven Marches, a single one (the 2020 crash) dominates the mean and the bootstrap interval includes zero
   - B. Because March has fewer sessions
   - C. Because March always goes down
   - D. Because it is a log mean

   *Answer A: Because with seven Marches, a single one (the 2020 crash) dominates the mean and the bootstrap interval includes zero.* A monthly effect is measured with one data point per year. With seven years, the mean of a month is the mean of seven numbers and a single event moves all of it.

## Key points

- A calendar effect is tested against the other days or months, not against zero, and with its t-statistic or a bootstrap interval.
- With many tests, chance significant results are the norm: 285 tests at 5% give about 14. Correct with Bonferroni, Holm or FDR and validate out of sample (lesson 2.1.8).
- A monthly effect has one data point per year: with seven years, a single event decides the mean.
- In this data no day-of-the-week, month or turn-of-the-month effect holds up; the p-value histogram is flat, the signature of nothing there.
- What is stable is the intraday profile of volatility and volume: a U with the open and the close well above midday.

## Resources

### Research

- [Seasonality in Python. Searching for Intraday Patterns](https://pythonparatrading.com/en/seasonality-intraday-preprocessing/) · Research-0024 · 9 min
- [Seasonality in Python II - Backtesting](https://pythonparatrading.com/en/seasonality-intraday-backtesting/) · Research-0025 · 8 min
- [Intraday Seasonality IV - Field Search](https://pythonparatrading.com/en/seasonality-intraday-optimization/) · Research-0027 · 7 min

### Documentation

- [SciPy: scipy.stats.bootstrap](https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.bootstrap.html) · docs.scipy.org
- [statsmodels: multipletests (Bonferroni, Holm, FDR)](https://www.statsmodels.org/stable/generated/statsmodels.stats.multitest.multipletests.html) · statsmodels.org

### Data

- [Level 1 data: NOVA 5-minute bars, 2024-2025 (Parquet)](https://pythonparatrading.com/curso/datos/nivel-1/nova_barras_5min.parquet) · Download · nova_barras_5min.parquet

Previous lesson: [1.6.2 · Stylized facts: fat tails, clustering, autocorrelation and leverage](https://pythonparatrading.com/en/python-trading-course/level-1/stylized-facts-of-financial-markets/)

Next lesson: [1.7.1 · Technical indicators in Python](https://pythonparatrading.com/en/python-trading-course/level-1/technical-indicators-in-python/)
