Lesson 1.7.3 · 70 min

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.

Fig. — NOVA (adjusted), 27 October to 31 December 2025: daily candles with the detected patterns and, dashed, the last confirmed resistance and support (swing highs and lows with 5 sessions on each side)
03/04
Module
1.7 · Programmed technical analysis
Duration
70 min
Updated
Level 1 lessons
  1. 1.1 · How markets work

    1. 1.1.1 Market participants and trading venues
    2. 1.1.2 Sessions, auctions, holidays and calendars
    3. 1.1.3 Order book: bid, ask, spread and depth
    4. 1.1.4 Order types
    5. 1.1.5 Costs, settlement, leverage and margin
  2. 1.2 · Asset classes

    1. 1.2.1 Stocks: splits, dividends and corporate actions
    2. 1.2.2 ETFs: replication, tracking error, leveraged ETFs and UCITS
    3. 1.2.3 Futures: specifications, margin, roll and contango
    4. 1.2.4 Forex: pairs, pips, lots, rollover and CFDs
    5. 1.2.5 Options: calls, puts, intrinsic value and time value
    6. 1.2.6 Crypto: spot, perpetuals, funding and custody
    7. 1.2.7 Fixed income and interest rates; CFDs and leveraged products
  3. 1.3 · Market data sources

    1. 1.3.1 Types of market data: EOD, bars, ticks, order book and reference
    2. 1.3.2 Free market data sources
    3. 1.3.3 Retail and prosumer market data APIs
    4. 1.3.4 Professional market data providers
    5. 1.3.5 Crypto and on-chain data
    6. 1.3.6 Brokers as market data sources
    7. 1.3.7 Market data licensing and terms of use
    8. 1.3.8 How to choose a market data provider
  4. 1.4 · Financial data engineering

    1. 1.4.1 Downloading over REST and websockets: pagination, rate limits and retries
    2. 1.4.2 Data cleaning: missing values, bad ticks, gaps and time zones
    3. 1.4.3 Adjusting for splits and dividends: adjusted and unadjusted prices
    4. 1.4.4 Universes without survivorship bias: delistings, mergers and symbol changes
    5. 1.4.5 Building bars: time, tick, volume, dollar and imbalance
    6. 1.4.6 Continuous futures series: roll rules, difference adjustment and ratio adjustment
    7. 1.4.7 Storage: CSV, Parquet, partitioning, DuckDB, TimescaleDB and ArcticDB
    8. 1.4.8 Reproducible pipeline: download, validation, incremental updates and quality tests
  5. 1.5 · Fundamental and macro data

    1. 1.5.1 Financial statements, ratios, XBRL and SEC EDGAR
    2. 1.5.2 Macro data, economic calendars, estimates and transcripts
  6. 1.6 · Financial exploratory analysis

    1. 1.6.1 Returns, volatility, rolling correlations and drawdown
    2. 1.6.2 Stylized facts: fat tails, clustering, autocorrelation and leverage
    3. 1.6.3 Seasonality: day of the week, month, turn of the month and intraday sessions
  7. 1.7 · Programmed technical analysis

    1. 1.7.1 Technical indicators in Python
    2. 1.7.2 Your own implementation versus TA-Lib: validating results
    3. 1.7.3 Candlestick patterns and support and resistance
    4. 1.7.4 Multi-timeframe analysis without look-ahead

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)

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.

PatternSessionsUsual descriptionRule in this lesson
Doji1Open and close almost equal: indecisionBody ≤ 10% of the day's range
Hammer1Small body at the top, long lower shadow, after declinesLower shadow ≥ 2 bodies, upper ≤ 10% of the range, body > 5% of the range, close lower than 5 sessions ago
Bullish engulfing2Bullish candle whose body covers that of a previous bearish oneYesterday bearish, today bullish, open ≤ yesterday's close and close ≥ yesterday's open
Bearish engulfing2The mirror imageYesterday 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:

IN [01]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")
OUT [01]Adjusted NOVA and ATR
2011 sessions

The four quantities of each candle are vectorized subtractions:

IN [02]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)
OUT [02]Body, range and shadows
                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

IN [03]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")
OUT [03]Custom patterns
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):

IN [04]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
OUT [04]The same patterns with TA-Lib
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:

IN [05]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%}")
OUT [05]TA-Lib's doji, reproduced
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 tt is known at the close of tt; the first possible execution is the open of t+1t+1. The exit is set before looking at the data: here, the open of t+6t+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.

Pattern at tEntry t+1Exit t+6Net of costs
Mermaid source
flowchart LR
  P[Pattern at t] --> E[Entry t+1]
  E --> S[Exit t+6]
  S --> N[Net of costs]
IN [06]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)
OUT [06]Return after each pattern
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 tt-statistic reaches 2. The best, TA-Lib's hammer, makes 1.29% net over five sessions with t=1.97t = 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 tt-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:

IN [07]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)
OUT [07]Sensitivity of the doji to the threshold
       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.10t = 2.10. At 5%, t=0.31t = 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:

IN [08]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)
OUT [08]The hammer in two halves
            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.83t = 1.83) and 0.62% in the second (t=0.81t = 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|t| > 1.96 by chance, and the probability that any of them does would be 1−0.9533=82%1 - 0.95^{33} = 82\%.

IN [09]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]]
OUT [09]TA-Lib's 61 patterns
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

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/33p < 0.05/33 on each test, that is, ∣t∣>3.17|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∣|t| of the 33 worth?

IN [10]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}")
OUT [10]Placebo: 33 patterns of random dates
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∣|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.

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 kk sessions and the next kk.

The next kk. A local high with k=5k = 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:

High at t5 sessionsConfirmed t+5Usable level
Mermaid source
flowchart LR
  M[High at t] --> K[5 sessions]
  K --> F[Confirmed t+5]
  F --> U[Usable level]
IN [11]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)
OUT [11]Local highs and lows, on time and with look-ahead
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.

IN [12]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)
OUT [12]Resistance touches: actual, placebos and look-ahead
                       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

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 tt 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:

IN [13]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)
OUT [13]Levels from clustering of extremes in 2025
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 kk sessions on each side is known kk 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+kt+1+k). Is the result stable?
Solution
IN [14]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)
OUT [14]The hammer at several horizons
              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.85t = -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 tt that rises and falls with the horizon, on 54 cases, is what noise produces. Testing five horizons also multiplies the number of tests.

  1. 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
IN [15]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)
OUT [15]Support touches
                  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 (0.42⋅0.58/100\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.

  1. 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?

Each answer is folded under its question

Self-assessment

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

    • ATA-Lib has a bug
    • BThe custom rule uses unadjusted prices
    • CTA-Lib compares the body with 10% of the average range of the previous 10 sessions, not with the day's own range
    • DTA-Lib also counts hammers
    Show answer

    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?

    • AThe close of day t
    • BThe open of day t + 1
    • CThe open of day t
    • DThe low of day t + 1
    Show answer

    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?

    • AThat it works, because t is close to 2
    • BThat it works only in the second half
    • CThat more patterns need testing
    • DNothing: with 33 tests, the best of 33 random patterns has a median |t| of 2.68; the Bonferroni threshold at 5% is 3.17
    Show answer

    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?

    • AThe 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
    • BThe resistance works better in the short term
    • CThe ATR changes
    • DNothing: they are two different samples
    Show answer

    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?

    • AA 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
    • BA support that has never been touched
    • CThe session's average price
    • DA level computed with synthetic data
    Show answer

    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

Data