Python 3 and a terminal. No trading platform, no broker, no account and no
money. macOS and Linux already include Python; on Windows install it from
python.org with “Add python.exe to PATH” ticked, then check with
python3 --version. You do not need MetaTrader for this page.
This page reaches an uncomfortable result, and it is important to read what it does and does not mean. The prices here are generated, so there is nothing real for a level to be made of — finding no effect is the correct outcome and confirms the measurement works. It says nothing about real markets, and step 5 says so explicitly. What transfers is the method, and the last step shows you how to run it on your own data.
Nothing here is trading advice. Run the five files in order; the first writes a file the rest read.
What are Support and Resistance?
Support and resistance are price levels where buying or selling pressure is strong enough to pause or reverse a move. They are among the most fundamental concepts in technical analysis — every trader, regardless of strategy, should understand them.
Support is a price level where buying interest is strong enough to prevent the price from falling further. Think of it as a floor.
Resistance is a price level where selling pressure is strong enough to prevent the price from rising further. Think of it as a ceiling.
Why Levels Form
Support and resistance exist because of market psychology and order clustering:
- Memory — Traders remember prices where they bought or sold. When price returns to those levels, they act again.
- Pending orders — Large institutional orders cluster at round numbers and historical levels, creating real buying/selling walls.
- Self-fulfilling prophecy — Because so many traders watch the same levels, their collective actions reinforce those levels.
Identifying Key Levels
Look for price levels where the market has repeatedly bounced or stalled:
- Swing highs and lows — The most obvious turning points on the chart. Multiple touches at a similar price strengthen the level.
- Round numbers — Prices like 1.3000, 50.00, or 100.00 attract orders and often act as support/resistance.
- Previous day/week highs and lows — Institutional traders watch these levels closely.
- Gap levels — Price gaps often get "filled," and the edges of gaps serve as support/resistance.
Support and resistance are better thought of as zones (a price range) rather than exact lines. Price may overshoot a level by a few pips before reversing. Use a small buffer zone rather than expecting exact touches.
Role Reversal
One of the most important concepts: when a support level is broken, it often becomes resistance, and vice versa. This is called role reversal or polarity.
For example, if price breaks below a support level at 1.3000, that level often acts as resistance when price tries to recover. This happens because traders who bought at 1.3000 want to exit at breakeven, creating selling pressure.
Trend Lines
Trend lines are diagonal support/resistance levels drawn by connecting successive higher lows (uptrend) or lower highs (downtrend):
- Uptrend line — Connect two or more higher lows. Price bouncing off this line confirms the uptrend. A break below signals potential trend change.
- Downtrend line — Connect two or more lower highs. Price rejecting this line confirms the downtrend.
- Validity — A trend line becomes more significant with each touch. Two touches create the line; three or more validate it.
Key Candlestick Patterns
Candlestick patterns at support/resistance levels are powerful confirmation signals:
Pin Bar (Hammer/Shooting Star): A candle with a small body and a long wick. At support, a long lower wick (hammer) shows buyers rejected lower prices. At resistance, a long upper wick (shooting star) shows sellers rejected higher prices.
Engulfing Pattern: A candle that completely engulfs the previous candle's body. A bullish engulfing at support is a strong buy signal. A bearish engulfing at resistance is a strong sell signal.
Doji: A candle where open and close are nearly equal, showing indecision. At key levels, dojis often precede reversals. The direction of the next candle confirms the move.
Chart Patterns
Larger patterns formed by support and resistance include:
- Double Top/Bottom — Price tests a level twice and fails. A reliable reversal pattern.
- Head and Shoulders — Three peaks with the middle one highest. A classic trend reversal pattern.
- Triangle (ascending, descending, symmetrical) — Price compresses between converging trend lines before breaking out.
- Flag and Pennant — Short consolidation patterns within a trend, typically leading to continuation.
Find Support Levels, Then Find Out Whether They Are Real, in Five Steps
Support and resistance is the most-drawn thing on any chart and the least-tested. Everybody can point at a line price bounced off; almost nobody has asked the question that would settle it — does price behave differently at that line than at a price picked at random? In the next half hour you will compute pivot levels from the published formula, write a detector that finds levels in the data, and then build the control that most trading education skips entirely. The result on this page is uncomfortable and the method is the point. Every line of output below came from running these files.
Go: open a terminal in a folder you can write to — cd ~/Desktop on macOS or Linux, cd %USERPROFILE%\Desktop on Windows.
Do: save this as bars.py and run python3 bars.py.
"""Build OHLC bars, not just closes -- pivots need highs and lows."""
import random
random.seed(11)
price, bars = 100.0, []
REGIMES = [0.35, -0.30, 0.0, 0.35, -0.30, 0.0, 0.35, -0.30]
for i in range(400):
drift = REGIMES[i // 50]
o = price
price += drift + random.uniform(-1.2, 1.2)
c = price
h = max(o, c) + random.uniform(0.0, 0.8)
l = min(o, c) - random.uniform(0.0, 0.8)
bars.append((round(o, 2), round(h, 2), round(l, 2), round(c, 2)))
with open("bars.txt", "w") as f:
for o, h, l, c in bars:
f.write("%.2f %.2f %.2f %.2f\n" % (o, h, l, c))
print("bars written :", len(bars))
print("first bar : open %.2f high %.2f low %.2f close %.2f" % bars[0])
print("last bar : open %.2f high %.2f low %.2f close %.2f" % bars[-1])
print()
print("Every bar satisfies low <= min(open,close) and high >= max(open,close)")
ok = all(l <= min(o, c) and h >= max(o, c) for o, h, l, c in bars)
print("checked on all %d bars :" % len(bars), ok)
You should see: four hundred bars, each one internally consistent:
bars written : 400
first bar : open 100.00 high 100.68 low 99.26 close 100.24
last bar : open 125.64 high 126.47 low 125.01 close 125.80
Every bar satisfies low <= min(open,close) and high >= max(open,close)
checked on all 400 bars : True
The last check is not decoration. A bar whose high is below its close, or whose low is above its open, is impossible — and it is exactly the kind of corruption that appears in downloaded data and quietly poisons every level you derive from it. Validate the shape of price data before computing anything from it, because the arithmetic downstream will happily produce numbers either way.
If not: if the final check prints False, the high and low lines were swapped
— the high must be built from max(o, c) upward and the low from
min(o, c) downward.
Go: the same folder.
Do: save this as pivots.py and run python3 pivots.py.
"""The classic pivot formula -- arithmetic you can check on paper."""
bars = [tuple(map(float, line.split())) for line in open("bars.txt")]
def pivots(high, low, close):
p = (high + low + close) / 3.0
return {
"R2": p + (high - low),
"R1": 2 * p - low,
"P": p,
"S1": 2 * p - high,
"S2": p - (high - low),
}
# Use one day's range: bars 0-19 stand in for "yesterday".
day = bars[:20]
h = max(b[1] for b in day)
l = min(b[2] for b in day)
c = day[-1][3]
print("yesterday: high %.2f low %.2f close %.2f" % (h, l, c))
print()
lv = pivots(h, l, c)
for name in ("R2", "R1", "P", "S1", "S2"):
print(" %-3s %8.4f" % (name, lv[name]))
print()
print("hand arithmetic for P:")
print(" (%.2f + %.2f + %.2f) / 3 = %.4f" % (h, l, c, (h + l + c) / 3))
print(" function says %.4f -> agree: %s"
% (lv["P"], abs((h + l + c) / 3 - lv["P"]) < 1e-12))
print()
print("Note what went into that: three numbers from yesterday. The levels")
print("contain no information about today, no volume, and no order book.")
print("They are a formula applied to a range, which is worth remembering")
print("when someone calls them 'where the market will react'.")
You should see: five levels, and the hand arithmetic agreeing:
yesterday: high 106.92 low 98.61 close 106.29
R2 112.2500
R1 109.2700
P 103.9400
S1 100.9600
S2 95.6300
hand arithmetic for P:
(106.92 + 98.61 + 106.29) / 3 = 103.9400
function says 103.9400 -> agree: True
Note what went into that: three numbers from yesterday. The levels
contain no information about today, no volume, and no order book.
They are a formula applied to a range, which is worth remembering
when someone calls them 'where the market will react'.
Pivot points are worth doing first because they are completely mechanical: given yesterday's high, low and close, everybody who uses this formula draws the same five lines on the same chart. That is a genuine argument in their favour — a level many people can see is a level many people may act on.
It is also worth being clear about what went in. Three numbers from yesterday. No volume, no order book, nothing about today. Whatever these lines are, they are not a measurement of where buyers exist.
If not: if agree prints False, the day slice and the hand
calculation are using different bars — both must use bars 0 to 19 and the close of bar
19.
Go: the same folder.
Do: save this as levels.py and run python3 levels.py.
"""Find swing highs and lows, then cluster nearby ones into levels."""
bars = [tuple(map(float, line.split())) for line in open("bars.txt")]
highs = [b[1] for b in bars]
lows = [b[2] for b in bars]
def swings(values, width, want_max):
out = []
for i in range(width, len(values) - width):
window = values[i-width:i+width+1]
best = max(window) if want_max else min(window)
if values[i] == best and window.count(best) == 1:
out.append((i, values[i]))
return out
def cluster(points, tolerance):
"""Group prices into bands no WIDER than `tolerance`.
The obvious version compares each price with the previous one and chains:
a run of prices one tolerance apart all end up in a single 'level' that is
far wider than the tolerance. Compare with the band's FIRST member instead,
so the width of every cluster is bounded by construction.
"""
levels = []
for _, price in sorted(points, key=lambda p: p[1]):
if levels and price - levels[-1][0] <= tolerance:
levels[-1].append(price)
else:
levels.append([price])
return [(sum(g) / len(g), len(g)) for g in levels]
sh = swings(highs, 5, True)
sl = swings(lows, 5, False)
print("swing highs found :", len(sh))
print("swing lows found :", len(sl))
all_levels = cluster(sh + sl, tolerance=1.0)
touched_twice = [(p, n) for p, n in all_levels if n >= 2]
print()
print("clusters (tolerance 1.00) :", len(all_levels))
print("touched 2+ times :", len(touched_twice))
print()
print("the five most-touched levels:")
for price, n in sorted(touched_twice, key=lambda x: -x[1])[:5]:
print(" %8.2f touched %d times" % (price, n))
with open("levels.txt", "w") as f:
for price, n in touched_twice:
f.write("%.4f %d\n" % (price, n))
print()
print("Saved %d levels. Next step asks the only question that matters:" % len(touched_twice))
print("does price behave differently at these than at any other price?")
You should see: forty swing points collapsing into nine levels touched more than once:
swing highs found : 21
swing lows found : 19
clusters (tolerance 1.00) : 19
touched 2+ times : 9
the five most-touched levels:
114.97 touched 5 times
111.10 touched 4 times
113.68 touched 4 times
106.64 touched 3 times
109.02 touched 3 times
Saved 9 levels. Next step asks the only question that matters:
does price behave differently at these than at any other price?
Read the docstring on cluster, because the obvious version of that
function has a bug that the first draft of this page shipped. Comparing each price with the
previous one chains: a run of prices each within the tolerance of the last all join one
group, and the result was a single “level” reported as touched seventeen times that
was in fact several points spread far wider than the tolerance.
That failure is worth more than the fix. A level-finder that chains will always find impressive levels, in any data, because widening a band until it swallows enough points is exactly how you manufacture a result. Comparing against the band's first member bounds the width by construction, so the tolerance means what it says.
If not: if one level reports far more touches than the others, the comparison inside
cluster is against levels[-1][-1] rather than
levels[-1][0] — that is the chaining bug described above, and reproducing it
deliberately is a worthwhile thirty seconds.
Go: the same folder. This is the step almost nobody performs.
Do: save this as control.py and run python3 control.py.
"""The only honest test: compare the real levels against random ones."""
import random
random.seed(99)
bars = [tuple(map(float, line.split())) for line in open("bars.txt")]
levels = [float(line.split()[0]) for line in open("levels.txt")]
lo = min(b[2] for b in bars)
hi = max(b[1] for b in bars)
def reaction_rate(level, tolerance=0.5, look=5):
"""Of the times price reached this level, how often did it turn away?"""
reached = turned = 0
for i in range(len(bars) - look):
o, h, l, c = bars[i]
if l - tolerance <= level <= h + tolerance:
reached += 1
after = bars[i + look][3]
if (c > level and after > c) or (c < level and after < c):
continue # carried on through
turned += 1
return reached, turned
def measure(levels_list, label):
total_reached = total_turned = 0
for lv in levels_list:
r, t = reaction_rate(lv)
total_reached += r
total_turned += t
rate = 100.0 * total_turned / total_reached if total_reached else 0.0
print("%-28s levels %2d touches %4d 'respected' %4d (%.1f%%)"
% (label, len(levels_list), total_reached, total_turned, rate))
return rate
real = measure(levels, "levels found in the data")
rates = []
for trial in range(20):
fake = [random.uniform(lo, hi) for _ in levels]
r = measure(fake, " random level set %2d" % (trial + 1)) if trial < 3 else None
if r is None:
total_reached = total_turned = 0
for lv in fake:
a, b = reaction_rate(lv)
total_reached += a; total_turned += b
r = 100.0 * total_turned / total_reached if total_reached else 0.0
rates.append(r)
avg = sum(rates) / len(rates)
print()
print("real levels : %.1f%%" % real)
print("random levels (avg of %d): %.1f%%" % (len(rates), avg))
print("difference : %+.1f percentage points" % (real - avg))
print()
print("If a level 'works', it must work BETTER than a price picked out of a")
print("hat. Without that comparison, any reaction rate sounds impressive --")
print("price bounces around constantly, so it 'respects' almost any number")
print("you nominate a good fraction of the time.")
You should see: the levels found in the data doing no better than prices drawn from a hat:
levels found in the data levels 9 touches 355 'respected' 164 (46.2%)
random level set 1 levels 9 touches 284 'respected' 149 (52.5%)
random level set 2 levels 9 touches 315 'respected' 153 (48.6%)
random level set 3 levels 9 touches 245 'respected' 121 (49.4%)
real levels : 46.2%
random levels (avg of 20): 48.5%
difference : -2.3 percentage points
If a level 'works', it must work BETTER than a price picked out of a
hat. Without that comparison, any reaction rate sounds impressive --
price bounces around constantly, so it 'respects' almost any number
you nominate a good fraction of the time.
46.2% against 48.5%. The levels the detector found are, if anything, marginally worse than random prices in the same range.
The reason a control is indispensable is in that 46.2% by itself. Presented alone, “price respected these levels 46% of the time” sounds like a finding. It is not, because price wanders constantly and will “respect” roughly half of any numbers you nominate. Without a baseline there is nothing to compare it to, and almost every published claim about chart patterns is quoted exactly this way — a hit rate with nothing beside it.
If not: FileNotFoundError: levels.txt means step 3 has not been run in this
folder. The random figures are seeded, so your numbers will match this page; changing
random.seed(99) will move them by a point or two without changing the
conclusion.
Go: the same folder.
Do: save this as pivot_test.py and run python3 pivot_test.py.
"""Run the same control on the pivot levels, and say what it does not prove."""
import random
random.seed(7)
bars = [tuple(map(float, line.split())) for line in open("bars.txt")]
lo = min(b[2] for b in bars); hi = max(b[1] for b in bars)
def pivot_levels(day):
h = max(b[1] for b in day); l = min(b[2] for b in day); c = day[-1][3]
p = (h + l + c) / 3.0
return [p + (h - l), 2*p - l, p, 2*p - h, p - (h - l)]
def reaction_rate(level, tolerance=0.5, look=5):
reached = turned = 0
for i in range(len(bars) - look):
o, h, l, c = bars[i]
if l - tolerance <= level <= h + tolerance:
reached += 1
after = bars[i + look][3]
if not ((c > level and after > c) or (c < level and after < c)):
turned += 1
return reached, turned
def measure(levels_list):
R = T = 0
for lv in levels_list:
r, t = reaction_rate(lv); R += r; T += t
return (100.0 * T / R if R else 0.0), R
# Pivots recomputed every 20 bars, the way a daily pivot works.
piv = []
for start in range(0, 380, 20):
piv.extend(pivot_levels(bars[start:start+20]))
real, touches = measure(piv)
rates = []
for _ in range(20):
rates.append(measure([random.uniform(lo, hi) for _ in piv])[0])
avg = sum(rates) / len(rates)
print("pivot levels tested :", len(piv))
print("touches :", touches)
print("pivots 'respected' : %.1f%%" % real)
print("random levels (avg 20): %.1f%%" % avg)
print("difference : %+.1f percentage points" % (real - avg))
print()
print("WHAT THIS SHOWS AND WHAT IT DOES NOT")
print()
print("Shows: on a series generated by a random-number generator, neither the")
print("swing levels nor the pivot levels beat a price drawn from a hat. That")
print("is the correct answer, because there is nothing in this data to find.")
print()
print("Does NOT show: that support and resistance are useless in real markets.")
print("Real prices carry things a random walk has none of -- resting orders,")
print("clustered stops, round numbers people actually type. Those could make")
print("levels real. This series cannot answer that question either way.")
print()
print("The transferable part is the METHOD: always compare against a control.")
print("A reaction rate with no random baseline beside it is not a measurement.")
You should see: pivots also indistinguishable from random, and an explicit limit on the claim:
pivot levels tested : 95
touches : 2839
pivots 'respected' : 49.4%
random levels (avg 20): 49.0%
difference : +0.3 percentage points
WHAT THIS SHOWS AND WHAT IT DOES NOT
Shows: on a series generated by a random-number generator, neither the
swing levels nor the pivot levels beat a price drawn from a hat. That
is the correct answer, because there is nothing in this data to find.
Does NOT show: that support and resistance are useless in real markets.
Real prices carry things a random walk has none of -- resting orders,
clustered stops, round numbers people actually type. Those could make
levels real. This series cannot answer that question either way.
The transferable part is the METHOD: always compare against a control.
A reaction rate with no random baseline beside it is not a measurement.
Read the two paragraphs the script prints at the end more carefully than the numbers above them, because the numbers are the easy part to over-read.
What this establishes is narrow and solid: in a series produced by a random-number generator, no level-finding method can beat chance, because there is nothing there to find. Getting that result is a check that the measurement works — a method that “found” real levels in random data would be broken.
What it does not establish is anything about real markets. Actual prices carry things this series has none of: resting orders at particular prices, clusters of stop losses, round numbers people genuinely type into boxes, and the fact that many traders draw the same lines and act on them. Any of those could make a level real. This data cannot tell you, and neither can anyone showing you a chart with the successful bounces circled.
So the thing to take away is the method rather than the verdict: get real data for your own instrument, run exactly this comparison, and see whether your levels beat the hat. Most platforms will export bars to CSV, and the code above needs only the four columns it already reads.
If not: if the difference is large in either direction, check random.seed(7) is
present — with only twenty random trials the average moves by a point or so between seeds,
which is itself a useful reminder about how many trials a comparison needs.
Without scrolling up: an article states that a particular chart pattern is followed by the expected move 63% of the time, based on 400 examples. What is the single most important thing missing, and what would you ask for? Answer: the baseline. 63% means nothing without knowing what a comparable set of arbitrary points would score on the same test — step 4 measured 46% for real levels and 48% for random ones, and a reader shown only the first number would have concluded something that the second number contradicts. So the thing to ask for is the control: what rate does the same measurement produce on randomly chosen points, or on the same pattern detected in shuffled data? Two further questions matter almost as much. How was “the expected move” defined, and was that definition fixed before the data was examined or adjusted afterwards until the number looked good? And 400 examples out of how many candidates — if the pattern's definition was loosened until 400 appeared, the count is a result of the search rather than of the market.
Now do it without the page: export a few hundred bars of a real instrument from your platform as CSV, reshape it
into the four columns bars.txt uses, and run steps 3, 4 and 5 unchanged on it. You
will get a real answer to a real question about a real market, which is more than most people
trading those levels have. Whichever way it comes out, you will have done something the article in
the question above did not.
Practical Application
A complete trading approach using support, resistance, and price action:
Identify the most obvious support and resistance zones where price has reacted multiple times.
Do not trade in the middle of a range. Wait patiently for price to approach your marked levels.
At the level, look for a rejection candlestick pattern confirmed by RSI or MACD divergence.
Place your stop-loss on the other side of the support/resistance zone. If the level breaks, your analysis was wrong and the stop protects you.