---
title: "1.7.3 Candlestick patterns and support: code and measure"
description: "Doji, hammer and engulfing with explicit thresholds and with TA-Lib, support and resistance from local highs, and how to measure whether any of them works."
url: "https://pythonparatrading.com/en/python-trading-course/level-1/candlestick-patterns-support-and-resistance/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-1/candlestick-patterns-support-and-resistance.png"
---

# 1.7.3 · Candlestick patterns and support and resistance

> A candlestick pattern is a rule on four prices; a support level, a rule on past highs and lows. Both can be coded and measured. This lesson codes them, tests them with realistic entries, costs and multiple testing, and shows why most of the “good” results disappear once it is done properly.

- Course: [Level 1: Financial data, markets and analysis](https://pythonparatrading.com/en/python-trading-course/level-1/)
- Module: 1.7 · Programmed technical analysis
- Duration: 70 min
- Prerequisites: [1.7.1 · Technical indicators in Python](https://pythonparatrading.com/en/python-trading-course/level-1/technical-indicators-in-python/), [0.7.2 · Probability and distributions, correlation versus causation and simple regression](https://pythonparatrading.com/en/python-trading-course/level-0/probability-distributions-and-regression/)

## By the end of this lesson

- Code doji, hammer and engulfing patterns with explicit thresholds and compare them with TA-Lib's `CDL` functions.
- Measure the return after a pattern without look-ahead: entry at the next open, exit at a fixed horizon, and costs.
- Recognize the three ways of fooling yourself with patterns: thresholds chosen after the fact, many tests at once, and few cases.
- Detect support and resistance from local highs and lows, know when they are actually known, and test them against placebo levels.

## A candle is four numbers

A Japanese candlestick summarizes a session with four prices: open, high, low and close. The **body** is the distance
between the open and the close (blue if the close is higher, orange if it is lower); the **shadows**, how far the price
traveled above and below the body. Candlestick patterns are rules on those quantities, over one, two or three
sessions, with names from the Japanese rice-trading tradition: doji, hammer, engulfing, morning star.

*Fig. — Anatomy of three candlestick patterns and the numeric rule the lesson uses to code them: doji, hammer and bullish engulfing (schematic).* Four hand-drawn candles. A doji: an almost empty body with long shadows above and below; rule: body at most 10% of the range. A bullish hammer with the body at the top, a tiny upper shadow and a long lower shadow, highlighted; rule: lower shadow of at least two bodies and upper shadow of at most 10% of the range. A bullish engulfing: a small bearish candle followed by a bullish one whose body covers the previous one.

The technical analysis literature describes them in words: “small body”, “long lower shadow”, “after a downtrend”. To
code them, each word has to be turned into a number, and that is where the problem starts: each number is a decision,
and each decision changes which sessions count as a pattern.

| Pattern | Sessions | Usual description | Rule in this lesson |
|---|---|---|---|
| Doji | 1 | Open and close almost equal: indecision | Body ≤ 10% of the day's range |
| Hammer | 1 | Small body at the top, long lower shadow, after declines | Lower shadow ≥ 2 bodies, upper ≤ 10% of the range, body > 5% of the range, close lower than 5 sessions ago |
| Bullish engulfing | 2 | Bullish candle whose body covers that of a previous bearish one | Yesterday bearish, today bullish, open ≤ yesterday's close and close ≥ yesterday's open |
| Bearish engulfing | 2 | The mirror image | Yesterday bullish, today bearish, open ≥ yesterday's close and close ≤ yesterday's open |

## The data and the quantities of each candle

The lesson uses NOVA adjusted for splits and dividends, like the previous two, from 2018 to 2025:

```python
import numpy as np
import pandas as pd
import talib

bruto = pd.read_csv("nova_diario_sin_ajustar.csv",
                    parse_dates=["fecha"], index_col="fecha")
eventos = pd.read_csv("nova_eventos.csv", parse_dates=["fecha_ex"])

def factores(bruto, eventos):
    cierre_ant = bruto["cierre"].shift(1)
    f = pd.Series(1.0, index=bruto.index)
    for e in eventos.itertuples():
        antes = bruto.index < e.fecha_ex
        if e.tipo == "split":
            f[antes] /= e.ratio
        else:
            f[antes] *= 1 - e.importe / cierre_ant[e.fecha_ex]
    return f

px = bruto[["apertura", "maximo", "minimo", "cierre"]].mul(
    factores(bruto, eventos), axis=0)
px.columns = ["o", "h", "l", "c"]
o, h, l, c = (px[k] for k in "ohlc")
O, H, L, C = (px[k].to_numpy() for k in "ohlc")
atr = pd.Series(talib.ATR(H, L, C, 14), index=px.index)
print(len(px), "sessions")
```
```output
2011 sessions
```

The four quantities of each candle are vectorized subtractions:

```python
cuerpo = (c - o).abs()
rango = h - l
sombra_sup = h - np.maximum(o, c)
sombra_inf = np.minimum(o, c) - l
velas = pd.DataFrame({"o": o, "c": c, "cuerpo": cuerpo,
                      "rango": rango, "sombra_sup": sombra_sup,
                      "sombra_inf": sombra_inf})
velas.loc["2025-12-22":"2025-12-26"].round(2)
```
```output
                o      c  cuerpo  rango  sombra_sup  sombra_inf
fecha
2025-12-22  69.38  69.53    0.15   1.16        0.74        0.27
2025-12-23  69.51  69.83    0.32   0.96        0.12        0.52
2025-12-24  69.51  69.67    0.16   1.30        0.56        0.58
2025-12-26  69.48  68.43    1.05   1.36        0.06        0.25
```

December 22 and 24, 2025 are small-body days (0.15 and 0.16 USD, 13% and 12% of their range): almost doji, but not with
a 10% threshold. With a slightly higher threshold they would qualify. The section “Three ways to manufacture a pattern”
measures how much the result changes with that threshold.

## Doji, hammer and engulfing with explicit thresholds

```python
doji = cuerpo <= 0.10 * rango
cayendo = c < c.shift(5)
martillo = ((sombra_inf >= 2 * cuerpo) & (sombra_sup <= 0.10 * rango)
            & (cuerpo > 0.05 * rango) & cayendo)
ayer_baja, ayer_sube = c.shift(1) < o.shift(1), c.shift(1) > o.shift(1)
env_alcista = (ayer_baja & (c > o) & (o <= c.shift(1))
               & (c >= o.shift(1)))
env_bajista = (ayer_sube & (c < o) & (o >= c.shift(1))
               & (c <= o.shift(1)))
propios = {"doji": doji, "hammer": martillo,
           "bullish engulfing": env_alcista,
           "bearish engulfing": env_bajista}
pd.Series({k: int(m.sum()) for k, m in propios.items()},
          name="sessions")
```
```output
doji                 301
hammer                24
bullish engulfing     46
bearish engulfing     43
Name: sessions, dtype: int64
```

The two-session patterns use `shift(1)`: yesterday's candle, which is already known when today's is evaluated. None of
them looks ahead. TA-Lib comes with 61 pattern functions (`CDL…`) that return, per session, 0 if there is no pattern
and ±100 if there is (+100 bullish, −100 bearish; for some, ±80 for a less pronounced version):

```python
ta_doji = talib.CDLDOJI(O, H, L, C)
ta_martillo = talib.CDLHAMMER(O, H, L, C)
ta_env = talib.CDLENGULFING(O, H, L, C)
print("CDLENGULFING values:", sorted(set(ta_env)))

comparacion = pd.DataFrame({
    "propio": [doji.sum(), martillo.sum(), env_alcista.sum(),
               env_bajista.sum()],
    "talib": [(ta_doji != 0).sum(), (ta_martillo > 0).sum(),
              (ta_env > 0).sum(), (ta_env < 0).sum()],
    "ambos": [(doji & (ta_doji != 0)).sum(),
              (martillo & (ta_martillo > 0)).sum(),
              (env_alcista & (ta_env > 0)).sum(),
              (env_bajista & (ta_env < 0)).sum()],
}, index=list(propios))
comparacion
```
```output
CDLENGULFING values: [np.int32(-100), np.int32(-80), np.int32(0), np.int32(80), np.int32(100)]
                   propio  talib  ambos
doji                  301    409    272
hammer                 24     54     13
bullish engulfing      46     46     46
bearish engulfing      43     45     43
```

The engulfing patterns almost match: the rule is geometric and leaves little room. The doji and the hammer do not.
TA-Lib does not compare the body with the day's own range, but with the **average range of the previous 10 sessions**.
With that rule, the custom doji reproduces TA-Lib's exactly:

```python
rango_medio = rango.rolling(10).mean().shift(1)
doji_ta = cuerpo <= 0.10 * rango_medio
print("Sessions:", doji_ta.sum(), "| equal to CDLDOJI:",
      f"{(doji_ta == (ta_doji != 0)).mean():.1%}")
```
```output
Sessions: 409 | equal to CDLDOJI: 100.0%
```

Both definitions are reasonable. TA-Lib's calls a doji a day with a small body **relative to how much NOVA usually
moves**, even if that day's range was narrow; the custom one, a day whose body is small **for that day**. TA-Lib's
hammer adds more conditions of that kind (shadows compared with multi-session averages, position relative to the
previous candle). Neither is “the correct one”. What matters is that the result of a pattern study depends on the
definition, and that it has to be published along with the result.

## Measuring without fooling yourself

The measurement has to respect time. The pattern of session $t$ is known at the close of $t$; the first possible
execution is the **open of $t+1$**. The exit is set before looking at the data: here, the open of $t+6$ (five sessions
in the position). Costs, 0.10% per round trip (commission and half the bid-ask spread on each side, lesson 1.1.5). A
bearish pattern is measured as a sale.

```mermaid
flowchart LR
  P[Pattern at t] --> E[Entry t+1]
  E --> S[Exit t+6]
  S --> N[Net of costs]
```

```python
COSTE = 0.001
fwd = o.shift(-6) / o.shift(-1) - 1  # open t+1 to open t+6

def evaluar(mascara, signo=1):
    r = (signo * fwd[mascara]).dropna() - COSTE
    t = r.mean() / r.std() * np.sqrt(len(r))
    return {"n": len(r), "neta_pct": 100 * r.mean(), "t": t}

casos = {
    "doji (custom)": (doji, 1), "hammer (custom)": (martillo, 1),
    "bull. engulf. (custom)": (env_alcista, 1),
    "bear. engulf. (custom)": (env_bajista, -1),
    "CDLDOJI": (ta_doji != 0, 1), "CDLHAMMER": (ta_martillo > 0, 1),
    "CDLENGULFING +": (ta_env > 0, 1),
    "CDLENGULFING -": (ta_env < 0, -1),
}
res = pd.DataFrame({k: evaluar(*v) for k, v in casos.items()}).T
print(f"Any session, buying: {100 * (fwd.mean() - COSTE):.3f}%"
      f" net over 5 sessions")
res.astype({"n": int}).round(3)
```
```output
Any session, buying: 0.172% net over 5 sessions
                          n  neta_pct      t
doji (custom)           299     0.475  1.362
hammer (custom)          24     1.641  1.648
bull. engulf. (custom)   46    -0.657 -0.829
bear. engulf. (custom)   43    -0.111 -0.160
CDLDOJI                 407     0.471  1.712
CDLHAMMER                54     1.294  1.968
CDLENGULFING +           46    -0.657 -0.829
CDLENGULFING -           45    -0.090 -0.133
```

No $t$-statistic reaches 2. The best, TA-Lib's hammer, makes 1.29% net over five sessions with $t = 1.97$, on 54
cases. Before celebrating, three checks.

The first: buying **any** session already gave 0.17% net, because NOVA rises over the period. Part of what the bullish
patterns earn is the stock's drift, not the pattern. The second: the five-session returns of nearby patterns overlap
(two doji on consecutive days share four of their five days), so the $t$-statistics are inflated. The third, the most
important, comes next.

## Three ways to manufacture a pattern that works

**Choose the threshold after looking.** A “small body” can be 3%, 10% or 30% of the range. Each threshold gives a
different set of sessions:

```python
umbrales = [0.03, 0.05, 0.10, 0.20, 0.30]
pd.DataFrame({f"{u:.0%}": evaluar(cuerpo <= u * rango)
              for u in umbrales}).T.astype({"n": int}).round(3)
```
```output
       n  neta_pct      t
3%    80     0.894  1.341
5%   145     0.152  0.309
10%  299     0.475  1.362
20%  596     0.499  2.103
30%  901     0.252  1.379
```

At 20%, the doji “works”: $t = 2.10$. At 5%, $t = 0.31$. If only the 20% is published, the reader sees a significant
pattern; if all five are published, they see noise. A real effect should not depend so much on an arbitrary threshold.

**Look at a single period.** Splitting the sample in two:

```python
mitad = "2021-12-31"
m = ta_martillo > 0
pd.DataFrame({"2018-2021": evaluar(m & (px.index <= mitad)),
              "2022-2025": evaluar(m & (px.index > mitad))}
             ).T.astype({"n": int}).round(3)
```
```output
            n  neta_pct      t
2018-2021  28     1.919  1.828
2022-2025  26     0.622  0.810
```

The hammer makes 1.92% net in the first half ($t = 1.83$) and 0.62% in the second ($t = 0.81$), with about 27 cases in
each. With so few cases, the difference between the halves is as large as the effect.

**Test many and keep the best.** This is the big trap. TA-Lib has 61 patterns; on NOVA, 33 appear at least 10 times.
If none had any effect, each would have a 5% probability of giving $|t| > 1.96$ by chance, and the probability that
**any** of them does would be $1 - 0.95^{33} = 82\%$.

```python
patrones = talib.get_function_groups()["Pattern Recognition"]
filas = {}
for nombre in patrones:
    senal = np.sign(getattr(talib, nombre)(O, H, L, C))
    senal = pd.Series(senal, index=px.index)
    hay = senal != 0
    if hay.sum() >= 10:
        r = (senal[hay] * fwd[hay]).dropna() - COSTE
        filas[nombre] = {"n": len(r), "neta_pct": 100 * r.mean(),
                         "t": r.mean() / r.std() * np.sqrt(len(r))}
todos = pd.DataFrame(filas).T.astype({"n": int})
print(len(patrones), "patterns;", len(todos), "with 10 or more cases;",
      (todos["t"].abs() > 1.96).sum(), "with |t| > 1.96")
todos.sort_values("t").round(3).iloc[[0, 1, 2, -3, -2, -1]]
```
```output
61 patterns; 33 with 10 or more cases; 1 with |t| > 1.96
                       n  neta_pct      t
CDLSHOOTINGSTAR       27    -1.728 -1.940
CDLGAPSIDESIDEWHITE   13    -2.374 -1.785
CDLADVANCEBLOCK       12    -1.248 -1.469
CDLDOJI              407     0.471  1.712
CDLLONGLEGGEDDOJI    405     0.486  1.756
CDLHAMMER             54     1.294  1.968
```

*Fig. — t statistic of the 5-session net return after each of the 33 TA-Lib patterns that appear at least 10 times in NOVA, with the thresholds for a single test, the best of 33 placebos and Bonferroni.* 33 points sorted from lowest to highest, one per TA-Lib pattern with at least 10 cases in NOVA, with its t statistic between −1.94 and 1.97. Horizontal lines at ±1.96 (a single test at 5%), ±2.68 (median of the best of 33 placebos) and ±3.17 (Bonferroni for 33 tests). The worst is the shooting star, −1.94; the best, highlighted, the hammer, at 1.97, barely above 1.96 and far below the other two lines.

Only one of the 33 exceeds 1.96: the hammer, at 1.968, by eight thousandths. The worst is the shooting star, at −1.94:
a bearish pattern whose “sale” loses money. With 33 tests and no real effect, it was to be expected that one would pass.
To know whether 1.97 is a lot **with 33 tests**, there are two tools. The Bonferroni correction requires
$p < 0.05/33$ on each test, that is, $|t| > 3.17$. And the placebo simulation answers directly: if dates are chosen at
random with the same number of cases as each real pattern, what is the best $|t|$ of the 33 worth?

```python
from scipy.stats import norm

ns = todos["n"].to_numpy()
rentas = fwd.dropna().to_numpy()
rng = np.random.default_rng(2027)
maximos = []
for _ in range(1000):
    ts = []
    for n in ns:
        signo = rng.choice([-1, 1])
        r = signo * rng.choice(rentas, n, replace=False) - COSTE
        ts.append(r.mean() / r.std() * np.sqrt(n))
    maximos.append(np.max(np.abs(ts)))
maximos = np.array(maximos)
print(f"Best |t| of 33 placebos: median {np.median(maximos):.2f},"
      f" 95th percentile {np.percentile(maximos, 95):.2f}")
print(f"Placebos with a maximum |t| >= 1.97: "
      f"{(maximos >= 1.97).mean():.1%}")
print(f"Bonferroni threshold (33 tests, 5%): "
      f"{norm.ppf(1 - 0.05 / 66):.2f}")
```
```output
Best |t| of 33 placebos: median 2.68, 95th percentile 3.58
Placebos with a maximum |t| >= 1.97: 96.0%
Bonferroni threshold (33 tests, 5%): 3.17
```

At the median, the best of 33 invented patterns has a $|t|$ of 2.68; the best real pattern, 1.97. TA-Lib's candlestick
patterns behave on NOVA **worse than randomly chosen dates**: in 96.0% of the simulations the placebo finds a “pattern”
better than the hammer, and the placebo's 95th percentile, 3.58, is even above the Bonferroni threshold.

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

The NOVA data is synthetic: it comes from a model with no candlestick patterns, so the result was to be expected. What
the lesson teaches is the procedure, not the conclusion. On real data the published evidence is mixed: Lo, Mamaysky and
Wang (2000) find that some classic chart patterns provide incremental information in US stocks from 1962 to 1996, and
Osler (2000) that the support and resistance levels published by six currency firms helped predict intraday trend
interruptions. The only way to know whether something works in a given market is to measure it like this: realistic
entry, costs, placebo and all the tests in plain view.

</aside>

## Support and resistance: local highs and lows

A **support** is a price at which a fall has stopped before; a **resistance**, one at which a rise has stopped. The most
common way to code them is with **local highs and lows** (*swing highs* and *swing lows*): a high is local if it is the
highest of the previous $k$ sessions and the next $k$.

The **next** $k$. A local high with $k = 5$ is only known to be one five sessions later: until then, any following
session could exceed it. The direct pandas version, a centered window, flags it on the very day it forms:

```mermaid
flowchart LR
  M[High at t] --> K[5 sessions]
  K --> F[Confirmed t+5]
  F --> U[Usable level]
```

```python
K = 5
es_max = h == h.rolling(2 * K + 1, center=True).max()
es_min = l == l.rolling(2 * K + 1, center=True).min()
print("Local highs:", es_max.sum(), "| lows:", es_min.sum())

# the last CONFIRMED level: the high of t-K is known at t
resistencia = h.where(es_max).shift(K).ffill()
soporte = l.where(es_min).shift(K).ffill()
# the same WITHOUT waiting for confirmation (look-ahead)
resistencia_mal = h.where(es_max).ffill()
niveles = pd.DataFrame({"h": h, "resistencia": resistencia,
                        "resistencia_mal": resistencia_mal,
                        "soporte": soporte})
niveles.loc["2025-12-01":"2025-12-05"].round(2)
```
```output
Local highs: 114 | lows: 112
                h  resistencia  resistencia_mal  soporte
fecha
2025-12-01  68.92        68.30            71.34    64.13
2025-12-02  67.79        71.34            71.34    64.13
2025-12-03  67.68        71.34            71.34    64.13
2025-12-04  67.10        71.34            71.34    64.13
2025-12-05  66.43        71.34            71.34    64.13
```

`shift(K)` is what separates a known level from a guessed one. The high of November 24, 2025, 71.34, is a resistance in
the version that does not wait from that very day; the confirmed one does not include it until December 2, five
sessions later (with the Thanksgiving holiday in between). That is why on December 1 one uses 71.34 and the other is
still at 68.30. The difference looks small in a table. In a trading rule it is huge.

The figure at the top of the page draws the last 46 sessions of 2025 with the detected patterns (10 doji, a bullish
engulfing on October 31 and a bearish one on December 9) and the confirmed levels as dashed lines. The local high of
October 31, 68.30, can only be used as resistance from November 7, five sessions later; that day NOVA closes at 65.94,
more than two dollars below.

## Are the levels respected? Testing against placebos

The test: every time the day's high reaches the last confirmed resistance coming from below (yesterday's close was
below it), does the price close below the level five sessions later? If the resistance is real, it should happen more
often than with any other level. The other levels are **placebos**: the same level shifted half an ATR or one ATR up
or down, which nobody has seen as resistance.

```python
def toques(nivel):
    toque = (h >= nivel) & (c.shift(1) < nivel)
    respeta = (c.shift(-5) < nivel)[toque & c.shift(-5).notna()]
    return {"toques": int(len(respeta)),
            "respeta_pct": 100 * respeta.mean()}

pruebas = {f"actual {d:+.1f} ATR": resistencia + d * atr
           for d in [-1.0, -0.5, 0.0, 0.5, 1.0]}
pruebas["with look-ahead"] = resistencia_mal
pruebas["prior 20-session high"] = h.rolling(20).max().shift(1)
pd.DataFrame({k: toques(n) for k, n in pruebas.items()}).T.round(1)
```
```output
                       toques  respeta_pct
actual -1.0 ATR         192.0         44.8
actual -0.5 ATR         192.0         43.8
actual +0.0 ATR         146.0         47.3
actual +0.5 ATR         113.0         60.2
actual +1.0 ATR          85.0         56.5
with look-ahead         211.0         67.3
prior 20-session high   288.0         47.6
```

*Fig. — How often a resistance is “respected” in NOVA (number of touches in brackets): the actual one confirmed in time, four shifted placebos, the high of the previous 20 sessions and the look-ahead version.* Horizontal bars with the percentage of resistance touches after which the close five sessions later is below the level, with a line at 50%. Actual confirmed resistance: 47.3% of 146 touches. Placebos one and half an ATR below: 44.8% and 43.8%, with 192 touches each; half an ATR and one ATR above: 60.2% and 56.5%. High of the previous 20 sessions: 47.6% of 288. With look-ahead, highlighted: 67.3% of 211 touches.

The actual resistance, confirmed on time, is “respected” on 47.3% of its 146 touches: it is broken more often than it
holds. The placebos shifted downward give almost the same (44.8% and 43.8%), and the ones shifted upward, **more**
(60.2% and 56.5%): a level above all the recent highs is only touched after a strong rise, and after a strong rise the
price usually gives some of it back. None of that has anything to do with the level being special.

And the look-ahead version gives 67.3%: a resistance that works two times out of three. It is a textbook mirage. The
local high of day $t$ is, by construction, higher than the next five highs; if it is used as a level on the same day,
the price **has** to stay below it. An indicator vendor showing “perfect” support and resistance on the chart is usually
showing this.

The high of the previous 20 sessions (the Donchian channel from lesson 1.7.1, already shifted) is the other usual way of
defining a resistance. It is touched 288 times and respected on 47.6%: the same as the local-high resistance and less
than a coin toss.

## Price clustering

Another definition of a level: the prices where many highs and lows have concentrated, as in a volume profile (lesson
1.7.1) but with session extremes. With the 2025 extremes in 0.50 USD buckets:

```python
a2025 = px.loc["2025"]
extremos = pd.concat([a2025["h"], a2025["l"]])
tramo = (np.floor(extremos / 0.5) * 0.5).round(2)
cuenta = tramo.value_counts().sort_index()
print("Buckets with extremes:", len(cuenta), "| mean:",
      round(cuenta.mean(), 1), "extremes per bucket")
cuenta.sort_values(ascending=False).head(5)
```
```output
Buckets with extremes: 55 | mean: 9.1 extremes per bucket
67.5    25
51.5    24
52.0    24
50.5    21
51.0    20
Name: count, dtype: int64
```

The buckets with the most extremes are simply the prices where NOVA spent the most time in 2025: from 50.50 to 52.50
(the May-to-August range) and 67.50 (the October-to-December one). A clustering like this describes where the price
was; that it anticipates where it will stop is another hypothesis, and it is tested the same way as the local highs:
with touches, placebos and costs.

## What remains

- **Coding is easy; measuring well is the work.** Entry at the next open, exit set in advance, costs, comparison with
  trading on any day.
- **Publish every test.** Thresholds, horizons and patterns tried. The number of tests is part of the result (lesson
  1.6.2 and module 3).
- **Always use placebos.** Random dates for patterns, shifted levels for support. If the placebo works as well, there is
  nothing there.
- **Local extremes are confirmed late.** A high with $k$ sessions on each side is known $k$ sessions later.
- **Adjusted prices move the levels.** A support on the backward-adjusted series is not the price that was seen on
  screen (lesson 1.7.1); for absolute levels, use the period's unadjusted series or readjust on each date.

## Exercises

1. Repeat the measurement of TA-Lib's hammer with horizons of 1, 3, 10 and 20 sessions (exit at the open of
   $t+1+k$). Is the result stable?

**Solution.**

```python
def evaluar_h(mascara, k, signo=1):
    r = (signo * (o.shift(-1 - k) / o.shift(-1) - 1)[mascara]).dropna()
    r = r - COSTE
    return {"n": len(r), "neta_pct": 100 * r.mean(),
            "t": r.mean() / r.std() * np.sqrt(len(r))}

pd.DataFrame({f"{k} sessions": evaluar_h(ta_martillo > 0, k)
              for k in [1, 3, 5, 10, 20]}).T.astype({"n": int}).round(3)
```
```output
              n  neta_pct      t
1 sessions   54    -0.185 -0.852
3 sessions   54     0.246  0.533
5 sessions   54     1.294  1.968
10 sessions  54     1.399  1.246
20 sessions  54     2.224  1.254
```

The horizon is another free parameter, and the result changes with it: $t = -0.85$ at one session, 0.53 at three, 1.97
at five and 1.25 at ten and at twenty. If the hammer had an effect, one would expect an orderly pattern (for example,
gains that grow and then level off); a $t$ that rises and falls with the horizon, on 54 cases, is what noise produces.
Testing five horizons also multiplies the number of tests.

2. Count how many times the price touches the last confirmed **support** from above (the day's low falls to the level
   and yesterday's close was above it) and in what percentage it closes above it five sessions later. Compare it with
   the half-ATR placebos.

**Solution.**

```python
def toques_soporte(nivel):
    toque = (l <= nivel) & (c.shift(1) > nivel)
    aguanta = (c.shift(-5) > nivel)[toque & c.shift(-5).notna()]
    return {"toques": int(len(aguanta)),
            "aguanta_pct": 100 * aguanta.mean()}

pd.DataFrame({f"support {d:+.1f} ATR": toques_soporte(soporte + d * atr)
              for d in [-0.5, 0.0, 0.5]}).T.round(1)
```
```output
                  toques  aguanta_pct
support -0.5 ATR    93.0         37.6
support +0.0 ATR   100.0         42.0
support +0.5 ATR   129.0         36.4
```

The confirmed support holds on 42.0% of its 100 touches, somewhat more than the placebos (37.6% and 36.4%). But with
100 touches the standard error of a percentage like that is about 5 points ($\sqrt{0.42 \cdot 0.58 / 100}$): the
difference is within the noise. And, like resistance, the support is broken more often than it holds.

3. A vendor advertises a candlestick pattern scanner “with a 72% hit rate”. What five questions would you ask before
   believing it?

**Solution.**

1. Hit rate measured how? Entry price at the pattern's close or at the next open, and exit at what horizon, set
   beforehand or chosen afterward? 2. Net of costs and compared with what? 72% of up days in a rising stock may be
   nothing but drift. 3. How many patterns, thresholds, horizons and assets were tested to get to that 72%, and how
   many cases are behind it? 4. Are the levels and patterns computed with the information available at each moment
   (confirmed local highs, no `center=True`)? 5. Does it work out of the sample in which it was designed, over a period
   and on assets the vendor has not seen?

## Self-assessment

1. TA-Lib flags 409 doji on NOVA and the rule “body ≤ 10% of the day's range” flags 301. Why?

   - A. TA-Lib has a bug
   - B. The custom rule uses unadjusted prices
   - C. TA-Lib compares the body with 10% of the average range of the previous 10 sessions, not with the day's own range
   - D. TA-Lib also counts hammers

   *Answer C: TA-Lib compares the body with 10% of the average range of the previous 10 sessions, not with the day's own range.* With TA-Lib's definition (average range of the previous 10 candles), the custom rule gives exactly the same 409 sessions. Two reasonable definitions of the same pattern give different sets, and therefore different results.

2. A pattern appears at the close of day t. What is the realistic entry for measuring its return?

   - A. The close of day t
   - B. The open of day t + 1
   - C. The open of day t
   - D. The low of day t + 1

   *Answer B: The open of day t + 1.* The pattern only exists once the candle for t has closed. Buying at that same close means acting before knowing there was a pattern; the first possible execution is the next open (or the closing auction if it is computed a few minutes earlier, which is a different rule).

3. You test 33 patterns and the best one has t = 1.97. What do you conclude?

   - A. That it works, because t is close to 2
   - B. That it works only in the second half
   - C. That more patterns need testing
   - D. Nothing: with 33 tests, the best of 33 random patterns has a median |t| of 2.68; the Bonferroni threshold at 5% is 3.17

   *Answer D: Nothing: with 33 tests, the best of 33 random patterns has a median |t| of 2.68; the Bonferroni threshold at 5% is 3.17.* With many tests, the maximum of the statistics grows even when there is nothing. The right question is not whether the best one exceeds 2, but whether it exceeds what chance would give with the same number of tests.

4. A “resistance” defined by local highs with 5 sessions on each side is “respected” on 67% of touches. With the level confirmed five sessions later, only on 47%. What happened?

   - A. The first calculation uses the local high on the very day it forms, and for it to be one the price has to fall over the next five sessions: look-ahead
   - B. The resistance works better in the short term
   - C. The ATR changes
   - D. Nothing: they are two different samples

   *Answer A: The first calculation uses the local high on the very day it forms, and for it to be one the price has to fall over the next five sessions: look-ahead.* A local high is only known to be one once the sessions on the right side of the window have passed. Using it earlier puts the fall that defines it into the rule. Confirmed in time, the level is broken more often than it is respected.

5. What is a placebo level and what is it for?

   - A. A level shifted on purpose (for example, the actual one plus or minus one ATR) to check whether the actual one behaves differently from any level
   - B. A support that has never been touched
   - C. The session's average price
   - D. A level computed with synthetic data

   *Answer A: A level shifted on purpose (for example, the actual one plus or minus one ATR) to check whether the actual one behaves differently from any level.* If the touches of an invented support behave like those of the actual one, the support adds nothing. On NOVA, the levels shifted half an ATR or one ATR upward are “respected” more than the actual one.

## Key points

- A candlestick pattern is a rule with thresholds: how much is a “small body” or a “long shadow” has to be stated in numbers. Different reasonable definitions give different sets of sessions.
- Honest measurement enters at the open after the pattern, exits at a horizon set before looking, deducts costs and compares with what trading on any day would have given.
- Testing many patterns, thresholds or horizons and keeping the best manufactures results: the 20% doji threshold gives t = 2.10 on NOVA; the 5% one, 0.31.
- On the NOVA data none of the 33 patterns with at least 10 cases exceeds |t| = 2, and the best stays below the typical maximum of 33 random patterns. The data is synthetic and has no patterns in it; on real data the published evidence is mixed.
- A local high or low with k sessions on each side is only known k sessions later. Using it earlier turns any resistance into a perfect resistance.
- Support and resistance are tested against placebo levels: if a shifted level works as well or better, the level is not special.

## Resources

### Research

- [Alpha Factors and Alpha Trading in Python](https://pythonparatrading.com/en/alpha-factors-and-alpha-trading-in-python/) · Research-0007 · 5 min
- [Stochastic Processes - Random Walks](https://pythonparatrading.com/en/stochastic-processes/) · Research-0005 · 11 min

### Documentation

- [TA-Lib for Python: pattern recognition functions (CDL)](https://ta-lib.github.io/ta-lib-python/func_groups/pattern_recognition.html) · ta-lib.github.io

### Books

- [Andrew W. Lo, Harry Mamaysky and Jiang Wang, “Foundations of Technical Analysis”, NBER Working Paper 7613 (2000)](https://www.nber.org/papers/w7613) · nber.org
- [Carol Osler, “Support for Resistance: Technical Analysis and Intraday Exchange Rates”, FRBNY Economic Policy Review 6(2), 2000](https://www.newyorkfed.org/research/epr/00v06n2/0007osle) · newyorkfed.org

### Data

- [Level 1 data: NOVA daily, unadjusted, 2018-2025 (CSV)](https://pythonparatrading.com/curso/datos/nivel-1/nova_diario_sin_ajustar.csv) · Download · nova_diario_sin_ajustar.csv

Previous lesson: [1.7.2 · Your own implementation versus TA-Lib: validating results](https://pythonparatrading.com/en/python-trading-course/level-1/validating-indicators-against-ta-lib/)

Next lesson: [1.7.4 · Multi-timeframe analysis without look-ahead](https://pythonparatrading.com/en/python-trading-course/level-1/multi-timeframe-analysis-without-look-ahead/)
