Skip to content

Support, Resistance & Price Action

Identify key price levels, trend lines, and candlestick patterns for smarter trading decisions.

💡
Before you start

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.
💡
Levels are zones, not exact lines

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.

1
Build bars with highs and lows, not just closes

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.

2
Compute pivot levels from the published formula

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.

3
Find levels in the data instead of assuming them

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.

4
Build the control, and let it answer

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.

5
Test the pivots too, then be careful about what you conclude

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.

🎉
Check yourself before moving on

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:

1
Mark key levels on higher timeframes (D1, H4)

Identify the most obvious support and resistance zones where price has reacted multiple times.

2
Wait for price to reach a key level

Do not trade in the middle of a range. Wait patiently for price to approach your marked levels.

3
Look for confirmation (candlestick pattern + indicator)

At the level, look for a rejection candlestick pattern confirmed by RSI or MACD divergence.

4
Set stop-loss beyond the level

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.