Bullish and Bearish Engulfing: Compare Two Candles with Python

Recent AAPL chart with a bullish or bearish engulfing pair identified by Python

Hammer and Shooting Star were one-candle shapes. Engulfing introduces something new: we need two neighboring candles.

one-candle pattern
→ measure one candle

two-candle pattern
→ compare previous and current candles

This lesson also marks another transition in our Python code. The candle drawing code is now long enough that repeating every common line in every future lesson would hide the new idea.

So we will build one reusable base file. Nothing is hidden: the full base code and the full lesson code are both shown below.

1. Why Split the Code into Two Files?

Look at what keeps repeating:

download recent OHLC data
measure body and wicks
draw candlesticks
format dates
save PNG
show chart

Engulfing itself does not need to redefine all of those jobs.

The new idea in this lesson is only:

compare previous body
with current body

So our folder will contain:

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

2. What Is a Python Module?

A Python file can contain reusable functions. Another Python file can import those functions and use them.

That reusable file is often called a module.

core file
→ reusable functions

lesson file
→ imports those functions
→ adds today's new logic

This is not about making the code mysterious. It is about keeping repeated code in one understandable place.

3. The Engulfing Rule Still Stays Visible

The main lesson function is:

def classify_engulfing(
    previous_row,
    current_row,
):

Bullish Engulfing:

previous bearish
AND
current bullish
AND
current Open <= previous Close
AND
current Close >= previous Open

Bearish Engulfing reverses those relationships.

4. Engulfing the Body Does Not Mean Engulfing the Wicks

This lesson compares the Open-Close bodies.

we require:
current body covers previous body

we do NOT require:
current High > previous High
AND
current Low < previous Low

Keeping that definition explicit matters because different sources can use different rules.

5. Complete Reusable Core Code

Create a new file named:

alphesta_candlestick_core_v1.py

Copy the complete code below into it.

from pathlib import Path
from datetime import date, timedelta
import os

import FinanceDataReader as fdr
import matplotlib.pyplot as plt


# ============================================================
# ALPHESTA CANDLESTICK CORE v1
# Reusable building blocks for Phase 2
# ============================================================


def set_working_folder(script_file):
    """
    Use the folder containing the lesson Python file
    as the working folder.
    """
    script_dir = Path(script_file).resolve().parent
    os.chdir(script_dir)

    print("Working folder:")
    print(script_dir)
    print()

    return script_dir


def download_recent_ohlc(
    symbol,
    calendar_days=220,
    trading_days=120,
):
    """
    Download recent daily OHLC data and keep
    the most recent trading rows.
    """
    today = date.today()

    start_date = (
        today - timedelta(days=calendar_days)
    ).strftime("%Y-%m-%d")

    end_date = today.strftime("%Y-%m-%d")

    df = fdr.DataReader(
        symbol,
        start_date,
        end_date,
    )

    df = df.tail(trading_days)

    if df.empty:
        raise ValueError(
            "No market data was downloaded."
        )

    print("Rows available:", len(df))
    print(
        "First date:",
        df.index[0].strftime("%Y-%m-%d"),
    )
    print(
        "Last date :",
        df.index[-1].strftime("%Y-%m-%d"),
    )
    print()

    return df


def measure_candle(row):
    """
    Convert one OHLC row into reusable
    candle measurements.
    """
    open_price = float(row["Open"])
    high_price = float(row["High"])
    low_price = float(row["Low"])
    close_price = float(row["Close"])

    body_top = max(
        open_price,
        close_price,
    )

    body_bottom = min(
        open_price,
        close_price,
    )

    body_size = abs(
        close_price - open_price
    )

    full_range = (
        high_price - low_price
    )

    upper_wick = (
        high_price - body_top
    )

    lower_wick = (
        body_bottom - low_price
    )

    if full_range > 0:
        body_share = (
            body_size / full_range
        )

        body_bottom_position = (
            (body_bottom - low_price)
            / full_range
        )

        body_top_position = (
            (body_top - low_price)
            / full_range
        )
    else:
        body_share = 0.0
        body_bottom_position = 0.0
        body_top_position = 0.0

    if body_size > 0:
        upper_wick_to_body = (
            upper_wick / body_size
        )

        lower_wick_to_body = (
            lower_wick / body_size
        )
    else:
        upper_wick_to_body = None
        lower_wick_to_body = None

    return {
        "open": open_price,
        "high": high_price,
        "low": low_price,
        "close": close_price,
        "body_top": body_top,
        "body_bottom": body_bottom,
        "body_size": body_size,
        "full_range": full_range,
        "upper_wick": upper_wick,
        "lower_wick": lower_wick,
        "body_share": body_share,
        "body_bottom_position":
            body_bottom_position,
        "body_top_position":
            body_top_position,
        "upper_wick_to_body":
            upper_wick_to_body,
        "lower_wick_to_body":
            lower_wick_to_body,
    }


def make_chart_window(
    dataframe,
    target_position=None,
    before=29,
    after=10,
    fallback_rows=40,
):
    """
    Keep a small chart window around a target row.
    If there is no target, use the latest rows.
    """
    if target_position is None:
        return dataframe.tail(
            fallback_rows
        ).copy()

    window_start = max(
        0,
        target_position - before,
    )

    window_end = min(
        len(dataframe),
        target_position + after + 1,
    )

    return dataframe.iloc[
        window_start:window_end
    ].copy()


def draw_candlestick_chart(
    dataframe,
    output_file,
    title,
    highlight_dates=None,
    highlight_label=None,
    marker_side="below",
    candle_width=0.6,
    date_tick_step=4,
    figure_width=11,
    figure_height=6.5,
    figure_dpi=100,
    save_dpi=120,
    title_fontsize=20,
    label_fontsize=14,
    tick_fontsize=10,
    annotation_fontsize=11,
):
    """
    Draw a candlestick chart from scratch.

    highlight_dates can contain one or more dates.
    """
    fig, ax = plt.subplots(
        figsize=(
            figure_width,
            figure_height,
        ),
        dpi=figure_dpi,
    )

    print(
        "Figure size (inches):",
        fig.get_size_inches(),
    )
    print(
        "Figure DPI:",
        fig.get_dpi(),
    )
    print()

    # ----------------------------------------
    # Draw every candlestick
    # ----------------------------------------

    for i, (
        date_index,
        row,
    ) in enumerate(
        dataframe.iterrows()
    ):
        m = measure_candle(row)

        candle_color = (
            "green"
            if m["close"] >= m["open"]
            else "red"
        )

        visible_body_height = (
            m["body_size"]
            if m["body_size"] > 0
            else max(
                m["full_range"] * 0.01,
                0.01,
            )
        )

        # Wick
        ax.vlines(
            x=i,
            ymin=m["low"],
            ymax=m["high"],
            color=candle_color,
            linewidth=1.6,
        )

        # Body
        ax.bar(
            x=i,
            height=visible_body_height,
            bottom=m["body_bottom"],
            width=candle_width,
            color=candle_color,
            edgecolor=candle_color,
        )

    # ----------------------------------------
    # Highlight one or more target candles
    # ----------------------------------------

    if highlight_dates:
        valid_dates = [
            d
            for d in highlight_dates
            if d in dataframe.index
        ]

        if valid_dates:
            x_values = [
                dataframe.index.get_loc(d)
                for d in valid_dates
            ]

            pair_low = float(
                dataframe.loc[
                    valid_dates,
                    "Low",
                ].min()
            )

            pair_high = float(
                dataframe.loc[
                    valid_dates,
                    "High",
                ].max()
            )

            chart_range = (
                float(
                    dataframe["High"].max()
                )
                - float(
                    dataframe["Low"].min()
                )
            )

            marker_offset = (
                chart_range * 0.04
                if chart_range > 0
                else 1
            )

            if marker_side == "above":
                y_value = (
                    pair_high
                    + marker_offset
                )

                text_y = (
                    pair_high
                    + marker_offset * 3
                )

                marker = "v"

                arrow_y = pair_high

            else:
                y_value = (
                    pair_low
                    - marker_offset
                )

                text_y = (
                    pair_low
                    - marker_offset * 3
                )

                marker = "^"

                arrow_y = pair_low

            ax.scatter(
                x_values,
                [
                    y_value
                    for _ in x_values
                ],
                marker=marker,
                s=75,
                label=highlight_label,
            )

            if highlight_label:
                center_x = (
                    min(x_values)
                    + max(x_values)
                ) / 2

                ax.annotate(
                    highlight_label,
                    xy=(
                        center_x,
                        arrow_y,
                    ),
                    xytext=(
                        center_x,
                        text_y,
                    ),
                    ha="center",
                    fontsize=
                        annotation_fontsize,
                    arrowprops=dict(
                        arrowstyle="->",
                    ),
                )

                ax.legend(
                    fontsize=
                        annotation_fontsize,
                )

    # ----------------------------------------
    # Date labels
    # ----------------------------------------

    tick_positions = list(
        range(
            0,
            len(dataframe),
            date_tick_step,
        )
    )

    tick_labels = [
        dataframe.index[i].strftime(
            "%Y-%m-%d"
        )
        for i in tick_positions
    ]

    ax.set_xticks(
        tick_positions
    )

    ax.set_xticklabels(
        tick_labels,
        rotation=45,
        ha="right",
    )

    # ----------------------------------------
    # Readability
    # ----------------------------------------

    ax.set_title(
        title,
        fontsize=title_fontsize,
    )

    ax.set_ylabel(
        "Price",
        fontsize=label_fontsize,
    )

    ax.tick_params(
        axis="both",
        labelsize=tick_fontsize,
    )

    ax.grid(
        axis="y",
        alpha=0.20,
    )

    ax.set_xlim(
        -1,
        len(dataframe),
    )

    # Avoid tight_layout() so memory use stays predictable.
    fig.subplots_adjust(
        left=0.10,
        right=0.97,
        top=0.90,
        bottom=0.23,
    )

    fig.savefig(
        output_file,
        dpi=save_dpi,
    )

    print("Chart saved:")
    print(output_file)
    print()
    print(
        "A chart window will now open."
    )
    print(
        "Close the chart window "
        "when you are finished viewing it."
    )

    plt.show()

    # Release figure memory.
    plt.close(fig)

6. Complete Phase 2-7 Lesson Code

In the same folder, create:

alphesta_phase2_07_bullish_bearish_engulfing_python_v1_1.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-7
# Bullish and Bearish Engulfing
# ============================================================


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

symbol = "AAPL"
recent_trading_days = 120


# ------------------------------------------------------------
# 2. LESSON FUNCTION
#    This is the new logic for Phase 2-7.
# ------------------------------------------------------------

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


# ------------------------------------------------------------
# 3. Set working folder
# ------------------------------------------------------------

SCRIPT_DIR = set_working_folder(
    __file__
)


# ------------------------------------------------------------
# 4. Download recent OHLC data
# ------------------------------------------------------------

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

if len(df) < 2:
    raise ValueError(
        "At least two trading days are needed "
        "to compare neighboring candles."
    )


# ------------------------------------------------------------
# 5. Scan every neighboring candle pair
# ------------------------------------------------------------

engulfing_candidates = []

for i in range(
    1,
    len(df),
):
    previous_row = df.iloc[i - 1]
    current_row = df.iloc[i]

    pattern_name = classify_engulfing(
        previous_row,
        current_row,
    )

    if pattern_name is not None:
        engulfing_candidates.append(
            {
                "type": pattern_name,
                "previous_date":
                    df.index[i - 1],
                "date":
                    df.index[i],
            }
        )


# ------------------------------------------------------------
# 6. Print the results
# ------------------------------------------------------------

print("Educational engulfing rule:")
print()

print("Bullish engulfing:")
print(
    "1. Previous body is bearish"
)
print(
    "2. Current body is bullish"
)
print(
    "3. Current Open <= previous Close"
)
print(
    "4. Current Close >= previous Open"
)
print()

print("Bearish engulfing:")
print(
    "1. Previous body is bullish"
)
print(
    "2. Current body is bearish"
)
print(
    "3. Current Open >= previous Close"
)
print(
    "4. Current Close <= previous Open"
)
print()

print(
    "Engulfing candidates found:",
    len(engulfing_candidates),
)
print()

for candidate in engulfing_candidates:
    print(
        candidate["date"].strftime(
            "%Y-%m-%d"
        ),
        "|",
        candidate["type"],
        "| previous:",
        candidate[
            "previous_date"
        ].strftime(
            "%Y-%m-%d"
        ),
    )

print()


# ------------------------------------------------------------
# 7. Select the most recent candidate
# ------------------------------------------------------------

target_position = None
highlight_dates = None
highlight_label = None

if engulfing_candidates:

    target = engulfing_candidates[-1]

    target_position = (
        df.index.get_loc(
            target["date"]
        )
    )

    highlight_dates = [
        target["previous_date"],
        target["date"],
    ]

    highlight_label = (
        target["type"]
    )

    print(
        "Most recent candidate:",
        target["type"],
    )
    print(
        "Previous date:",
        target[
            "previous_date"
        ].strftime(
            "%Y-%m-%d"
        ),
    )
    print(
        "Current date :",
        target["date"].strftime(
            "%Y-%m-%d"
        ),
    )
    print()

else:

    print(
        "No recent candle pair matched "
        "this exact educational rule."
    )
    print(
        "That is a valid result. "
        "Do not force a pattern."
    )
    print()


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

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


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

output_file = (
    SCRIPT_DIR
    / "engulfing_candlestick_candidate.png"
)

draw_candlestick_chart(
    dataframe=chart_df,
    output_file=output_file,
    title=(
        f"{symbol} — "
        "Bullish and Bearish Engulfing"
    ),
    highlight_dates=highlight_dates,
    highlight_label=highlight_label,
    marker_side="below",
)

7. Run the Lesson File

Run:

python alphesta_phase2_07_bullish_bearish_engulfing_python_v1_1.py

The program will:

download recent AAPL data
→ compare neighboring candles
→ print Engulfing candidates
→ select the most recent candidate
→ draw the chart
→ save the PNG

The image is saved beside the Python files as:

engulfing_candlestick_candidate.png

8. Change One Boundary Yourself

In classify_engulfing(), find:

current["open"]
<= previous["close"]

Change the inclusive comparison to:

current["open"]
< previous["close"]

Do the corresponding strict-boundary change for the opposite edge. Then rerun the program.

Ask:

Did fewer pairs qualify?

Which pairs disappeared?

Does equality matter
in recent AAPL data?

9. Why This Structure Helps the Next Lessons

The reusable core can now remain unchanged.

Phase 2-8 can replace the lesson-specific logic with something like:

classify_context_pattern(...)

Phase 2-9 can focus on:

is_doji(...)

The reader still has the complete working program:

shared core
+
complete lesson file
=
complete runnable experiment

What You Just Learned

repeated mechanics
→ reusable module

new candlestick idea
→ small lesson function

two neighboring candles
→ Engulfing classification

This is the structure we will reuse through the rest of Phase 2.