Build Your First Candlestick Pattern Scanner with Python

AFTER RUNNING THE PYTHON CODE AND UPLOADING THE PNG TO BLOGGER, INSERT THE REAL IMAGE HERE AS THE FIRST BODY IMAGE.
Recent AAPL candlestick chart showing the latest pattern candidate found by a Python scanner

Until now, we tested one candlestick idea at a time.

Hammer
Doji
Engulfing
Shooting Star

Now we are ready to ask a different question:

Can one Python program
check several pattern rules
for us?

Yes. That is what a scanner does.

This is the final Building Block in Phase 2. We are not adding another candlestick name. We are combining the rules we already understand.

1. Keep the Same Core

Put these two files in the same folder:

practice-folder/
├─ alphesta_candlestick_core_v1.py
└─ phase2_10_candlestick_pattern_scanner.py

The Core still handles the repeated mechanics:

download data
measure candles
build the chart window
draw candlesticks
save the PNG

The Phase 2-10 file contains the pattern rules and the scanner.

2. Which File Should You Run?

Keep both files together, but run the Phase 2-10 lesson file.

DO NOT run directly:
alphesta_candlestick_core_v1.py

RUN:
phase2_10_candlestick_pattern_scanner.py

In the terminal:

python phase2_10_candlestick_pattern_scanner.py

3. A Scanner Is Not a Strategy

This distinction is important.

scanner
→ finds candles that match rules

strategy
→ decides when to enter, exit,
   and manage risk

Phase 2-10 only does the first job.

A Hammer candidate is not automatically a long entry. A Shooting Star candidate is not automatically a short entry.

4. Turn Each Idea into a Function

Our lesson file now contains several small building blocks:

get_preceding_context()
is_doji()
classify_engulfing()
classify_preceding_context_pattern()

Each function answers one question.

What was price doing before this candle?

Is this candle a Doji candidate?

Do these two candles form Engulfing?

Does this shape become
Hammer, Hanging Man,
Inverted Hammer, or Shooting Star?

Then one new function connects them:

scan_candlestick_patterns()

5. One Loop, Several Tests

The scanner moves through the DataFrame one row at a time.

for each candle
    ↓
measure it
    ↓
check Doji
    ↓
check Engulfing
    ↓
check shape + preceding context
    ↓
store every match

That is the key step from a single rule to a reusable research tool.

6. Why the Scanner Keeps Every Match

Pattern definitions do not always behave like exclusive boxes. One date can satisfy more than one explicit rule.

Our scanner does not force one winner. It stores every match it finds.

same date
→ one matching rule
or
→ several matching rules

That makes the assumptions visible. Later, we can test whether overlapping classifications matter.

7. Preceding Context Still Means Before

We keep the clearer name from Phase 2-8:

preceding_context

It always describes what price was doing before the current pattern candle.

Hammer
preceding context: falling
possible reversal afterward: bullish

Shooting Star
preceding context: rising
possible reversal afterward: bearish

8. Complete Phase 2-10 Python Code

Keep alphesta_candlestick_core_v1.py in the same folder. Create:

phase2_10_candlestick_pattern_scanner.py

Copy the complete code below.

from alphesta_candlestick_core_v1 import (
    set_working_folder,
    download_recent_ohlc,
    measure_candle,
    make_chart_window,
    draw_candlestick_chart,
)


# ============================================================
# Phase 2-10
# Build Your First Candlestick Pattern Scanner
# ============================================================


# ------------------------------------------------------------
# 1. Settings
# ------------------------------------------------------------

symbol = "AAPL"
recent_trading_days = 120

# Preceding price context
preceding_context_bars = 5

# Hammer / Hanging Man /
# Inverted Hammer / Shooting Star
min_long_wick_to_body = 2.0
max_short_wick_to_body = 1.0
min_body_bottom_position = 0.60
max_body_top_position = 0.40

# Doji
max_doji_body_share = 0.10


# ------------------------------------------------------------
# 2. Preceding-context helper
# ------------------------------------------------------------

def get_preceding_context(
    dataframe,
    row_number,
    bars,
):
    """
    Describe what price was doing BEFORE
    the current pattern candle.
    """

    recent_close = float(
        dataframe.iloc[
            row_number - 1
        ]["Close"]
    )

    earlier_close = float(
        dataframe.iloc[
            row_number - bars
        ]["Close"]
    )

    if recent_close > earlier_close:
        return "rising"

    if recent_close < earlier_close:
        return "falling"

    return "flat"


# ------------------------------------------------------------
# 3. Doji rule
# ------------------------------------------------------------

def is_doji(
    measurements,
):
    """
    A Doji candidate has a small body
    compared with its full High-Low range.
    """

    if measurements["full_range"] <= 0:
        return False

    return (
        measurements["body_share"]
        <= max_doji_body_share
    )


# ------------------------------------------------------------
# 4. Engulfing rule
# ------------------------------------------------------------

def classify_engulfing(
    previous_row,
    current_row,
):
    """
    Compare two neighboring candle bodies.

    Returns:
        "Bullish Engulfing"
        "Bearish Engulfing"
        None
    """

    previous = measure_candle(
        previous_row
    )

    current = measure_candle(
        current_row
    )

    previous_is_bearish = (
        previous["close"]
        < previous["open"]
    )

    previous_is_bullish = (
        previous["close"]
        > previous["open"]
    )

    current_is_bullish = (
        current["close"]
        > current["open"]
    )

    current_is_bearish = (
        current["close"]
        < current["open"]
    )

    is_bullish_engulfing = (
        previous_is_bearish
        and current_is_bullish
        and current["open"]
        <= previous["close"]
        and current["close"]
        >= previous["open"]
    )

    is_bearish_engulfing = (
        previous_is_bullish
        and current_is_bearish
        and current["open"]
        >= previous["close"]
        and current["close"]
        <= previous["open"]
    )

    if is_bullish_engulfing:
        return "Bullish Engulfing"

    if is_bearish_engulfing:
        return "Bearish Engulfing"

    return None


# ------------------------------------------------------------
# 5. Shape + preceding-context rule
# ------------------------------------------------------------

def classify_preceding_context_pattern(
    measurements,
    preceding_context,
):
    """
    Combine candle geometry with the price context
    that existed BEFORE the pattern candle.

    Returns:
        "Hammer"
        "Hanging Man"
        "Inverted Hammer"
        "Shooting Star"
        None
    """

    if (
        measurements[
            "upper_wick_to_body"
        ] is None
        or measurements[
            "lower_wick_to_body"
        ] is None
    ):
        return None

    is_lower_wick_shape = (
        measurements[
            "lower_wick_to_body"
        ]
        >= min_long_wick_to_body

        and measurements[
            "upper_wick_to_body"
        ]
        <= max_short_wick_to_body

        and measurements[
            "body_bottom_position"
        ]
        >= min_body_bottom_position
    )

    is_upper_wick_shape = (
        measurements[
            "upper_wick_to_body"
        ]
        >= min_long_wick_to_body

        and measurements[
            "lower_wick_to_body"
        ]
        <= max_short_wick_to_body

        and measurements[
            "body_top_position"
        ]
        <= max_body_top_position
    )

    if is_lower_wick_shape:

        if preceding_context == "falling":
            return "Hammer"

        if preceding_context == "rising":
            return "Hanging Man"

    if is_upper_wick_shape:

        if preceding_context == "falling":
            return "Inverted Hammer"

        if preceding_context == "rising":
            return "Shooting Star"

    return None


# ------------------------------------------------------------
# 6. Scanner
#    One loop calls several pattern functions.
# ------------------------------------------------------------

def scan_candlestick_patterns(
    dataframe,
):
    """
    Scan the DataFrame once and collect every
    pattern candidate found by our explicit rules.

    More than one pattern can be reported
    on the same date.
    """

    candidates = []

    for i in range(
        len(dataframe)
    ):
        current_row = dataframe.iloc[i]
        current_date = dataframe.index[i]

        measurements = measure_candle(
            current_row
        )

        # ------------------------------------
        # A. Doji
        # ------------------------------------

        if is_doji(
            measurements
        ):
            candidates.append(
                {
                    "date": current_date,
                    "pattern": "Doji",
                    "preceding_context": None,
                    "highlight_dates": [
                        current_date
                    ],
                }
            )

        # ------------------------------------
        # B. Bullish / Bearish Engulfing
        # ------------------------------------

        if i >= 1:

            previous_row = (
                dataframe.iloc[i - 1]
            )

            engulfing_pattern = (
                classify_engulfing(
                    previous_row=
                        previous_row,
                    current_row=
                        current_row,
                )
            )

            if engulfing_pattern is not None:
                candidates.append(
                    {
                        "date":
                            current_date,
                        "pattern":
                            engulfing_pattern,
                        "preceding_context":
                            None,
                        "highlight_dates": [
                            dataframe.index[
                                i - 1
                            ],
                            current_date,
                        ],
                    }
                )

        # ------------------------------------
        # C. Hammer-family patterns
        # ------------------------------------

        if i >= preceding_context_bars:

            preceding_context = (
                get_preceding_context(
                    dataframe=dataframe,
                    row_number=i,
                    bars=
                        preceding_context_bars,
                )
            )

            context_pattern = (
                classify_preceding_context_pattern(
                    measurements=
                        measurements,
                    preceding_context=
                        preceding_context,
                )
            )

            if context_pattern is not None:
                candidates.append(
                    {
                        "date":
                            current_date,
                        "pattern":
                            context_pattern,
                        "preceding_context":
                            preceding_context,
                        "highlight_dates": [
                            current_date
                        ],
                    }
                )

    return candidates


# ------------------------------------------------------------
# 7. Set the working folder
# ------------------------------------------------------------

SCRIPT_DIR = set_working_folder(
    __file__
)


# ------------------------------------------------------------
# 8. Download recent OHLC data
# ------------------------------------------------------------

df = download_recent_ohlc(
    symbol=symbol,
    calendar_days=260,
    trading_days=recent_trading_days,
)


# ------------------------------------------------------------
# 9. Run the scanner
# ------------------------------------------------------------

pattern_candidates = (
    scan_candlestick_patterns(
        dataframe=df,
    )
)


# ------------------------------------------------------------
# 10. Print every candidate
# ------------------------------------------------------------

print("Candlestick pattern scanner")
print("===========================")
print()

print(
    "Symbol:",
    symbol,
)

print(
    "Rows scanned:",
    len(df),
)

print(
    "Pattern candidates found:",
    len(pattern_candidates),
)

print()

for candidate in pattern_candidates:

    line = (
        candidate["date"].strftime(
            "%Y-%m-%d"
        )
        + " | "
        + candidate["pattern"]
    )

    if (
        candidate[
            "preceding_context"
        ]
        is not None
    ):
        line += (
            " | preceding context: "
            + candidate[
                "preceding_context"
            ]
        )

    print(line)

print()


# ------------------------------------------------------------
# 11. Count candidates by pattern
# ------------------------------------------------------------

pattern_counts = {}

for candidate in pattern_candidates:

    pattern_name = (
        candidate["pattern"]
    )

    pattern_counts[
        pattern_name
    ] = (
        pattern_counts.get(
            pattern_name,
            0,
        )
        + 1
    )

print("Candidate counts by pattern:")
print()

if pattern_counts:

    for (
        pattern_name,
        count,
    ) in sorted(
        pattern_counts.items()
    ):
        print(
            pattern_name,
            ":",
            count,
        )

else:
    print(
        "No pattern candidates "
        "matched the current rules."
    )

print()


# ------------------------------------------------------------
# 12. Select the latest date with a match
# ------------------------------------------------------------

target_position = None
highlight_dates = None
highlight_label = None

if pattern_candidates:

    latest_date = max(
        candidate["date"]
        for candidate
        in pattern_candidates
    )

    latest_candidates = [
        candidate
        for candidate
        in pattern_candidates
        if candidate["date"]
        == latest_date
    ]

    target_position = (
        df.index.get_loc(
            latest_date
        )
    )

    # One date can match more than one rule.
    latest_pattern_names = [
        candidate["pattern"]
        for candidate
        in latest_candidates
    ]

    highlight_label = (
        "\n".join(
            latest_pattern_names
        )
    )

    # Engulfing needs two highlighted candles.
    # Single-candle patterns need only one.
    highlight_dates = []

    for candidate in latest_candidates:

        for date_value in candidate[
            "highlight_dates"
        ]:

            if (
                date_value
                not in highlight_dates
            ):
                highlight_dates.append(
                    date_value
                )

    print(
        "Latest matching date:",
        latest_date.strftime(
            "%Y-%m-%d"
        ),
    )

    print(
        "Patterns:",
        ", ".join(
            latest_pattern_names
        ),
    )

    print()

else:

    print(
        "No recent candle matched "
        "the current scanner rules."
    )

    print(
        "That is a valid result."
    )

    print()


# ------------------------------------------------------------
# 13. Build a small chart window
# ------------------------------------------------------------

chart_df = make_chart_window(
    dataframe=df,
    target_position=target_position,
    before=29,
    after=10,
    fallback_rows=40,
)


# ------------------------------------------------------------
# 14. Draw and save the chart
# ------------------------------------------------------------

output_file = (
    SCRIPT_DIR
    / "candlestick_pattern_scanner.png"
)

draw_candlestick_chart(
    dataframe=chart_df,
    output_file=output_file,
    title=(
        f"{symbol} — "
        "Candlestick Pattern Scanner"
    ),
    highlight_dates=highlight_dates,
    highlight_label=highlight_label,
    marker_side="below",
)

9. Run the Scanner

python phase2_10_candlestick_pattern_scanner.py

The program will:

download recent AAPL data
→ scan every candle
→ apply several pattern functions
→ print every candidate
→ count candidates by pattern
→ find the latest matching date
→ highlight it on the chart
→ save the PNG

The chart is saved as:

candlestick_pattern_scanner.png

10. Read the Output

The first part of the output lists individual matches.

2026-...
| Doji

2026-...
| Hammer
| preceding context: falling

The next part counts how many candidates each rule found.

Candidate counts by pattern:

Bearish Engulfing : ...
Doji : ...
Hammer : ...
Shooting Star : ...

Your numbers will depend on the most recent data returned when you run the program.

11. Change the Stock, Not the Rules

For the final Phase 2 experiment, keep every pattern rule unchanged.

Change:

symbol = "AAPL"

to:

symbol = "MSFT"

Run the scanner again.

Compare:

How many patterns were found?

Which pattern appeared most often?

Did the latest matching pattern change?

The program is now reusable. The rules stay the same while the market data changes.

12. What Phase 2 Built

Phase 2 started with one OHLC row.

one OHLC row
→ one candlestick

many rows
→ candlestick chart

one candle
→ body and wick measurements

measurements
→ explicit pattern rules

pattern rules
→ functions

functions
→ scanner

That is a much more important skill than memorizing a long list of candlestick names.

What You Just Built

understand the rule
→ write the rule
→ turn it into a function
→ combine functions
→ let Python repeat the work

Phase 2 is now complete.

Where Do We Go Next?

So far, we have worked directly with price.

Next, we will begin transforming price into technical indicators. The first one should be simple enough to build ourselves: the moving average.

Phase 2
read price itself

        ↓

Phase 3
transform price
into indicators

We will start by understanding the calculation before asking pandas to do it for us.


Previous: Phase 2-9 — What Is a Doji? How Small Is “Small” in Python?

Next: Phase 3-1 — What Is a Moving Average? Build One from Prices with Python