What Is a Shooting Star Candlestick? Find It with Python

Recent AAPL candlestick chart with a shooting star candidate identified by Python

In the previous lesson, we turned a hammer from a picture into numbers.

Now we can reuse the same idea almost in reverse.

Hammer
→ long lower wick
→ body near the top

Shooting star
→ long upper wick
→ body near the bottom

This is useful because you are not learning a completely new programming technique. You are changing the measurements and conditions you already understand.

What you will finish: Python will scan recent AAPL candles for a shooting-star-shaped candidate and mark the most recent match on a chart.

1. Describe the Shape Before Naming It

A shooting star is usually drawn as:

long upper wick
small body near the bottom
small lower wick

That gives us three measurable ideas.

We do not need Python to recognize a picture. We need Python to compare lengths and positions.

2. Reuse the Same Candle Measurements

We still begin with:

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
)

This is one reason building the candle from scratch was useful. The same measurements can now support many later questions.

3. Make the Upper Wick the Main Feature

For a shooting-star shape, the upper wick should be large relative to the body.

upper_wick_to_body = (
    upper_wick / body_size
)

Our first educational threshold is:

upper wick
≥ 2 × body

In Python:

upper_wick_to_body >= 2.0

4. Keep the Lower Wick Small

Now measure the other side:

lower_wick_to_body = (
    lower_wick / body_size
)

For this lesson, require:

lower wick
≤ 1 × body

Or:

lower_wick_to_body <= 1.0

5. Put the Body Near the Bottom

In the hammer lesson, we measured whether the bottom of the body sat high in the total range. Now we reverse the idea.

Measure where the top of the body sits between Low and High:

body_top_position = (
    (body_top - low_price)
    / full_range
)

Read the scale like this:

0.0
→ body top is at the Low

0.4
→ body top is 40% up the range

1.0
→ body top is at the High

We want the body to remain in the lower part of the candle, so we use:

body_top_position <= 0.40

6. Combine the Three Conditions

is_shooting_star = (
    upper_wick_to_body >= 2.0
    and lower_wick_to_body <= 1.0
    and body_top_position <= 0.40
)

Again, and means every condition must be true.

long upper wick
AND
small lower wick
AND
body near the bottom
→ shooting-star candidate

7. Compare It with the Hammer

The logic is easier to remember when the two patterns are placed side by side.

HAMMER

lower wick / body
→ large

upper wick / body
→ small

body position
→ near top


SHOOTING STAR

upper wick / body
→ large

lower wick / body
→ small

body position
→ near bottom

The programming structure hardly changed. The definition changed.

That is exactly what we want: reusable code with explicit assumptions.

8. Search Recent Data

We again use dates generated at runtime:

today = date.today()

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

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

Then:

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

df = df.tail(120)

So the article continues to work with recent market data rather than depending on one old chart.

9. Search Every Candle

The loop is familiar:

for date_index, row in df.iterrows():

Each row goes through the same sequence:

OHLC
→ body and wick measurements
→ ratios
→ Boolean rule
→ candidate or not

If the rule is true:

shooting_star_candidates.append(...)

10. Zero Candidates Still Means the Code Worked

Recent market data may contain no candle that matches our exact thresholds.

If that happens, the script prints:

No recent candle matched
this exact educational rule.

That is not a reason to keep changing the rule until something appears.

A good experiment accepts zero as a possible answer.

11. Highlight the Most Recent Match

If candidates exist:

target = (
    shooting_star_candidates[-1]
)

selects the most recent match.

The chart then places a marker above that candle so you can compare the numeric result with the visual shape.

12. Shape Is Not Context

This distinction matters.

Our code currently identifies a shooting-star-shaped candle. Traditional interpretation often pays attention to whether this shape appears after a preceding price rise.

We have deliberately not added that context rule yet.

this lesson:
geometry only

not yet:
prior trend
resistance
volume
confirmation
future return

So do not read every candidate as a bearish reversal signal.

13. Keep the Chart Memory-Safe

We continue using the safer matplotlib settings introduced after our earlier rendering problem.

figure_width = 11
figure_height = 6.5
figure_dpi = 100

We do not use tight_layout(). We use:

fig.subplots_adjust(
    left=0.10,
    right=0.97,
    top=0.90,
    bottom=0.23,
)

and close the figure after viewing:

plt.close(fig)

14. Complete Code

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

import FinanceDataReader as fdr
import matplotlib.pyplot as plt

SCRIPT_DIR = Path(__file__).resolve().parent
os.chdir(SCRIPT_DIR)

symbol = "AAPL"

today = date.today()

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

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

recent_trading_days = 120

min_upper_wick_to_body = 2.0
max_lower_wick_to_body = 1.0
max_body_top_position = 0.40

chart_window = 40
candle_width = 0.6

figure_width = 11
figure_height = 6.5
figure_dpi = 100

title_fontsize = 20
label_fontsize = 14
tick_fontsize = 10
annotation_fontsize = 11

date_tick_step = 4

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

df = df.tail(
    recent_trading_days
)

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

shooting_star_candidates = []

for date_index, row in df.iterrows():

    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:
        continue

    if body_size <= 0:
        continue

    upper_wick_to_body = (
        upper_wick / body_size
    )

    lower_wick_to_body = (
        lower_wick / body_size
    )

    body_top_position = (
        (body_top - low_price)
        / full_range
    )

    is_shooting_star = (
        upper_wick_to_body
        >= min_upper_wick_to_body
        and lower_wick_to_body
        <= max_lower_wick_to_body
        and body_top_position
        <= max_body_top_position
    )

    if is_shooting_star:

        shooting_star_candidates.append(
            {
                "date": date_index,
                "open": open_price,
                "high": high_price,
                "low": low_price,
                "close": close_price,
                "body_size": body_size,
                "full_range": full_range,
                "upper_wick": upper_wick,
                "lower_wick": lower_wick,
                "upper_wick_to_body":
                    upper_wick_to_body,
                "lower_wick_to_body":
                    lower_wick_to_body,
                "body_top_position":
                    body_top_position,
            }
        )

print(
    "Shooting-star candidates found:",
    len(shooting_star_candidates),
)

for candidate in shooting_star_candidates:

    print(
        candidate["date"].strftime(
            "%Y-%m-%d"
        ),
        "| upper/body:",
        f'{candidate["upper_wick_to_body"]:.2f}',
        "| lower/body:",
        f'{candidate["lower_wick_to_body"]:.2f}',
        "| body position:",
        f'{candidate["body_top_position"]:.2f}',
    )

target_date = None

if shooting_star_candidates:

    target = (
        shooting_star_candidates[-1]
    )

    target_date = target["date"]

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

    window_start = max(
        0,
        target_position - 29,
    )

    window_end = min(
        len(df),
        target_position + 11,
    )

    chart_df = df.iloc[
        window_start:window_end
    ].copy()

else:

    chart_df = df.tail(
        chart_window
    ).copy()

fig, ax = plt.subplots(
    figsize=(
        figure_width,
        figure_height,
    ),
    dpi=figure_dpi,
)

for i, (date_index, row) in enumerate(
    chart_df.iterrows()
):

    open_price = float(
        row["Open"]
    )

    high_price = float(
        row["High"]
    )

    low_price = float(
        row["Low"]
    )

    close_price = float(
        row["Close"]
    )

    candle_color = (
        "green"
        if close_price >= open_price
        else "red"
    )

    body_bottom = min(
        open_price,
        close_price,
    )

    body_height = abs(
        close_price - open_price
    )

    visible_body_height = (
        body_height
        if body_height > 0
        else max(
            (high_price - low_price)
            * 0.01,
            0.01,
        )
    )

    ax.vlines(
        x=i,
        ymin=low_price,
        ymax=high_price,
        color=candle_color,
        linewidth=1.6,
    )

    ax.bar(
        x=i,
        height=visible_body_height,
        bottom=body_bottom,
        width=candle_width,
        color=candle_color,
        edgecolor=candle_color,
    )

if target_date is not None:

    target_x = (
        chart_df.index.get_loc(
            target_date
        )
    )

    target_high = float(
        chart_df.loc[
            target_date,
            "High",
        ]
    )

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

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

    ax.scatter(
        [target_x],
        [
            target_high
            + marker_offset
        ],
        marker="v",
        s=90,
        label="Shooting-star candidate",
    )

    ax.annotate(
        "Shooting-star candidate",
        xy=(
            target_x,
            target_high,
        ),
        xytext=(
            target_x,
            target_high
            + marker_offset * 3,
        ),
        ha="center",
        fontsize=annotation_fontsize,
        arrowprops=dict(
            arrowstyle="->",
        ),
    )

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

tick_labels = [
    chart_df.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",
)

ax.set_title(
    (
        f"{symbol} — "
        "Finding a Shooting Star"
    ),
    fontsize=title_fontsize,
)

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

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

if target_date is not None:
    ax.legend(
        fontsize=annotation_fontsize,
    )

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

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

fig.subplots_adjust(
    left=0.10,
    right=0.97,
    top=0.90,
    bottom=0.23,
)

output_file = (
    SCRIPT_DIR
    / "shooting_star_candlestick_candidate.png"
)

fig.savefig(
    output_file,
    dpi=120,
)

print()
print("Chart saved:")
print(output_file)

plt.show()
plt.close(fig)

15. Make the Rule Stricter Yourself

Find:

min_upper_wick_to_body = 2.0

Change it to:

min_upper_wick_to_body = 3.0

Run the code again.

You should usually expect fewer matches because the upper wick now has to be even longer relative to the body.

2.0
→ looser geometric definition

3.0
→ stricter geometric definition

The important lesson is not which number is "correct." The important lesson is that your assumption is now visible in the code.

What You Just Learned

visual idea
→ measurable geometry
→ explicit parameters
→ Boolean rule
→ recent candidates

You also reused almost the same programming structure as the hammer lesson. Only the definition changed.

That is the beginning of reusable research code.

Where Do We Go Next?

Hammer and shooting star are one-candle shapes.

Next we will move to a two-candle relationship: engulfing.

That introduces a new programming idea: comparing the body of one candle with the body of the candle immediately before it.