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.