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.
- Module
- 1.7 · Programmed technical analysis
- Duration
- 70 min
- Prerequisites
- 1.7.1 · Technical indicators in Python, 0.7.2 · Probability and distributions, correlation versus causation and simple regression
- Author
- Jesús Cuesta
- Updated
Level 1 lessons
1.1 · How markets work
1.2 · Asset classes
- 1.2.1 Stocks: splits, dividends and corporate actions
- 1.2.2 ETFs: replication, tracking error, leveraged ETFs and UCITS
- 1.2.3 Futures: specifications, margin, roll and contango
- 1.2.4 Forex: pairs, pips, lots, rollover and CFDs
- 1.2.5 Options: calls, puts, intrinsic value and time value
- 1.2.6 Crypto: spot, perpetuals, funding and custody
- 1.2.7 Fixed income and interest rates; CFDs and leveraged products
1.3 · Market data sources
- 1.3.1 Types of market data: EOD, bars, ticks, order book and reference
- 1.3.2 Free market data sources
- 1.3.3 Retail and prosumer market data APIs
- 1.3.4 Professional market data providers
- 1.3.5 Crypto and on-chain data
- 1.3.6 Brokers as market data sources
- 1.3.7 Market data licensing and terms of use
- 1.3.8 How to choose a market data provider
1.4 · Financial data engineering
- 1.4.1 Downloading over REST and websockets: pagination, rate limits and retries
- 1.4.2 Data cleaning: missing values, bad ticks, gaps and time zones
- 1.4.3 Adjusting for splits and dividends: adjusted and unadjusted prices
- 1.4.4 Universes without survivorship bias: delistings, mergers and symbol changes
- 1.4.5 Building bars: time, tick, volume, dollar and imbalance
- 1.4.6 Continuous futures series: roll rules, difference adjustment and ratio adjustment
- 1.4.7 Storage: CSV, Parquet, partitioning, DuckDB, TimescaleDB and ArcticDB
- 1.4.8 Reproducible pipeline: download, validation, incremental updates and quality tests
1.5 · Fundamental and macro data
1.6 · Financial exploratory analysis
1.7 · Programmed technical analysis
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.
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:
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")2011 sessionsThe four quantities of each candle are vectorized subtractions:
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) 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.25December 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
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")doji 301
hammer 24
bullish engulfing 46
bearish engulfing 43
Name: sessions, dtype: int64The 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):
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))
comparacionCDLENGULFING 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 43The 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:
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%}")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 is known at the close of ; the first possible execution is the open of . The exit is set before looking at the data: here, the open of (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 source
flowchart LR P[Pattern at t] --> E[Entry t+1] E --> S[Exit t+6] S --> N[Net of costs]
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)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.133No -statistic reaches 2. The best, TA-Lib's hammer, makes 1.29% net over five sessions with , 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 -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:
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) 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.379At 20%, the doji “works”: . At 5%, . 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:
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) n neta_pct t
2018-2021 28 1.919 1.828
2022-2025 26 0.622 0.810The hammer makes 1.92% net in the first half () and 0.62% in the second (), 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 by chance, and the probability that any of them does would be .
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]]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.968Only 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 on each test, that is, . 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 of the 33 worth?
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}")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.17At the median, the best of 33 invented patterns has a 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 sessions and the next .
The next . A local high with 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 source
flowchart LR M[High at t] --> K[5 sessions] K --> F[Confirmed t+5] F --> U[Usable level]
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)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.13shift(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.
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) 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.6The 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 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:
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)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: int64The 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 sessions on each side is known 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
- Repeat the measurement of TA-Lib's hammer with horizons of 1, 3, 10 and 20 sessions (exit at the open of ). Is the result stable?
Solution
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) 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.254The horizon is another free parameter, and the result changes with it: 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 that rises and falls with the horizon, on 54 cases, is what noise produces. Testing five horizons also multiplies the number of tests.
- 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
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) 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.4The 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 (): the difference is within the noise. And, like resistance, the support is broken more often than it holds.
- A vendor advertises a candlestick pattern scanner “with a 72% hit rate”. What five questions would you ask before believing it?
Solution
- 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
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.
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).
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.
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.
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.