In Phase 4-01, we stopped before calculating profit.
We first turned a vague idea into a reproducible event.
Indicator
→ SMA 20
State
→ Close > SMA 20
Event
→ Cross Above
Signal Time
→ after bar t Close
Planned Execution
→ bar t+1 Open
Now we can ask the next research question:
What happened
AFTER the event?
1. A Forward Return Looks into the Future from a Defined Time
A forward return measures the change from one reference point to a later point.
future value
------------- - 1
current value
For example, a 5-bar Close-to-Close forward return is:
Close(t+5)
---------- - 1
Close(t)
This is a future label. It tells us what happened after time t.
2. Why Call It a Label?
At time t, the future value does not exist yet.
information available at t
→ predictor / signal input
information after t
→ future label / outcome
Mixing those two sides is one of the easiest ways to create look-ahead bias.
3. Our First Horizons: 1, 5, and 20 Bars
We will calculate three simple horizons.
1 bar
5 bars
20 bars
These do not mean that one horizon is better. They simply let us ask the same question at different time scales.
4. There Are Two Different Forward Returns We Could Measure
This distinction is important because our signal uses the Close.
First, a descriptive Close-to-Close label:
Close(t)
→ Close(t+h)
Second, a return aligned with the execution convention from Phase 4-01:
signal known
at Close(t)
↓
entry
Open(t+1)
↓
exit
Open(t+1+h)
5. Close-to-Close Forward Return Is Useful but Not Our Execution Return
We can calculate:
Close(t+h)
---------- - 1
Close(t)
That is useful for an event study because it describes how price changed after the signal date.
But our signal is only finalized after Close(t) is known.
So we should not automatically treat Close(t) as an executable entry price.
6. The Executable Forward Return Uses the Next Open
We keep the convention frozen in Phase 4-01.
Entry
=
Open(t+1)
Exit for h-bar horizon
=
Open(t+1+h)
Therefore:
Executable Forward Return(h)
=
Open(t+1+h)
------------- - 1
Open(t+1)
7. Work Through a Tiny Example
Suppose a signal occurs after Monday's Close.
Tuesday Open = 100
Wednesday Open = 103
The 1-bar executable forward return is:
103 / 100 - 1
=
0.03
=
3%
The signal is associated with Monday, but the measured position begins at Tuesday's Open.
8. Build the Descriptive Close Forward Return
def forward_close_return(
close_values,
horizon,
):
...
result[i] = (
close_values[i + horizon]
/ close_values[i]
- 1.0
)
Notice that the future Close is stored at row i
as an outcome label. It must never become an input to the signal at row i.
9. Build the Next-Open Forward Return
entry_index = i + 1
exit_index = (
entry_index
+ horizon
)
return = (
Open(exit_index)
/ Open(entry_index)
- 1
)
This keeps the chronology explicit.
10. Why the Last Rows Become None
A 20-bar forward return needs 20 future bars after the entry.
end of dataset
↓
not enough future bars
↓
forward return = None
Those rows should be excluded from that horizon's summary, not silently filled with zero.
11. Reuse the Exact Phase 4-01 Signal
We do not invent a new signal in this lesson.
SMA 20
Cross Above:
Close(t-1) <= SMA20(t-1)
AND
Close(t) > SMA20(t)
Freezing the signal lets us change only one thing: the outcome measurement.
12. Build an Event Table
The program saves:
sma20_cross_above_forward_returns.csv
Each signal row contains both types of future labels.
Signal Date
OHLC
SMA 20
Close Fwd 1
Open Fwd 1
Close Fwd 5
Open Fwd 5
Close Fwd 20
Open Fwd 20
13. A Signal Average Alone Is Not Enough
Suppose the 20-bar average after Cross Above events is positive.
Can we conclude that the crossover helped?
No.
The market itself may have had positive 20-bar returns most of the time.
14. We Need a Baseline
Our first baseline is deliberately simple:
Signal Group
→ bars where Cross Above = True
Baseline Group
→ all eligible bars
Both groups use the exact same return definition:
Open(t+1)
→ Open(t+1+h)
15. Why Use the Same Return Definition for Both Groups?
A comparison is only meaningful if the measurement rule is consistent.
Signal Group
next Open → future Open
Baseline Group
next Open → future Open
Changing the timing rule between groups would create an unfair comparison.
16. Start with Descriptive Statistics
For each horizon, the script reports:
Count
Mean
Median
Positive Rate
These answer different questions.
Mean
→ average outcome
Median
→ middle outcome
Positive Rate
→ fraction above zero
Count
→ how many observations
17. Mean and Median Can Tell Different Stories
A few very large returns can pull the mean upward.
The median is less sensitive to extreme observations.
Mean high
Median modest
→ possibly influenced by
a small number of large outcomes
That is why Phase 4 should not rely on one summary number.
18. Positive Rate Is Not Win Rate of a Finished Strategy
Here, Positive Rate means only:
fraction of h-bar
forward returns > 0
It does not yet include the actual Cross Below exit rule, transaction costs, or full position accounting.
19. Overlapping Forward Returns Are a Hidden Problem
Consider two consecutive signal dates with a 20-bar horizon.
Their future windows can share many of the same bars.
signal A
|--------------------|
signal B
|--------------------|
large overlap
That means the observations are not automatically independent.
We will need more careful statistical treatment later.
20. A Baseline Difference Is Not Yet Evidence of an Edge
Suppose:
Cross Above mean return
>
All Bars mean return
That is interesting.
But it is still only a descriptive historical difference.
difference observed
≠
statistically reliable
≠
out-of-sample robust
≠
tradable after costs
21. Why We Use More History Than the Indicator Lessons
Indicator lessons could work with a short chart window.
Research needs more events.
few observations
→ unstable summary
more observations
→ better descriptive picture
but still not proof
The script therefore keeps up to 800 recent trading days for this exercise.
22. The Program Produces Two Different Charts
First:
forward_return_events_aapl.png
AAPL candles
+ SMA 20
+ Cross Above markers
This verifies the event locations.
Second:
forward_return_baseline_comparison.png
1-bar
5-bar
20-bar
Cross Above mean
vs
All Eligible Bars mean
This is our first outcome comparison chart.
23. The Complete Python Program
This lesson reuses FinanceDataReader, pandas,
and matplotlib. Python's built-in statistics module
calculates mean and median for the descriptive summary.
phase4_02_forward_return.py
from pathlib import Path
from datetime import date, timedelta
from statistics import mean, median
import os
import FinanceDataReader as fdr
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
import pandas as pd
# ============================================================
# Phase 4-02 — Forward Return
# Measure what happened AFTER a signal
# ============================================================
# ------------------------------------------------------------
# 1. Settings
# ------------------------------------------------------------
symbol = "AAPL"
recent_trading_days = 800
sma_period = 20
forward_horizons = [1, 5, 20]
bullish_color = "seagreen"
bearish_color = "firebrick"
sma_color = "dimgray"
# ------------------------------------------------------------
# 2. Working folder
# ------------------------------------------------------------
SCRIPT_DIR = Path(__file__).resolve().parent
os.chdir(SCRIPT_DIR)
print("Working folder:")
print(SCRIPT_DIR)
print()
# ------------------------------------------------------------
# 3. Reuse Phase 4-01 blocks
# ------------------------------------------------------------
def simple_moving_average(values, period):
result = [None] * len(values)
if period <= 0:
raise ValueError("period must be positive")
for i in range(period - 1, len(values)):
window = values[i - period + 1 : i + 1]
result[i] = sum(float(v) for v in window) / period
return result
def above_sma_state(close_values, sma_values):
result = [None] * len(close_values)
for i in range(len(close_values)):
if sma_values[i] is None:
continue
result[i] = float(close_values[i]) > float(sma_values[i])
return result
def crossover_events(above_state):
cross_above = [False] * len(above_state)
cross_below = [False] * len(above_state)
for i in range(1, len(above_state)):
previous_state = above_state[i - 1]
current_state = above_state[i]
if previous_state is None or current_state is None:
continue
cross_above[i] = current_state is True and previous_state is False
cross_below[i] = current_state is False and previous_state is True
return cross_above, cross_below
# ------------------------------------------------------------
# 4. Statistical forward Close return
# ------------------------------------------------------------
def forward_close_return(close_values, horizon):
"""Close(t) -> Close(t+h).
This is useful as a descriptive future label.
It is NOT the executable return for a signal that is
only known after Close(t).
"""
result = [None] * len(close_values)
if horizon <= 0:
raise ValueError("horizon must be positive")
for i in range(0, len(close_values) - horizon):
start_close = float(close_values[i])
future_close = float(close_values[i + horizon])
result[i] = future_close / start_close - 1.0
return result
# ------------------------------------------------------------
# 5. Executable next-Open forward return
# ------------------------------------------------------------
def next_open_forward_return(open_values, horizon):
"""Open(t+1) -> Open(t+1+h).
Phase 4-01 defined the signal after Close(t).
Therefore the earliest simple planned execution is Open(t+1).
"""
result = [None] * len(open_values)
if horizon <= 0:
raise ValueError("horizon must be positive")
last_signal_index = len(open_values) - horizon - 2
for i in range(0, last_signal_index + 1):
entry_index = i + 1
exit_index = entry_index + horizon
entry_open = float(open_values[entry_index])
exit_open = float(open_values[exit_index])
result[i] = exit_open / entry_open - 1.0
return result
# ------------------------------------------------------------
# 6. Descriptive summary
# ------------------------------------------------------------
def summarize_returns(values):
valid_values = [float(v) for v in values if v is not None]
if not valid_values:
return {
"Count": 0,
"Mean": None,
"Median": None,
"Positive Rate": None,
}
positive_count = sum(v > 0.0 for v in valid_values)
return {
"Count": len(valid_values),
"Mean": mean(valid_values),
"Median": median(valid_values),
"Positive Rate": positive_count / len(valid_values),
}
# ------------------------------------------------------------
# 7. Draw candlesticks
# ------------------------------------------------------------
def draw_candlesticks(ax, market_df, body_width=0.62):
for x, (_, row) in enumerate(market_df.iterrows()):
open_price = float(row["Open"])
high_price = float(row["High"])
low_price = float(row["Low"])
close_price = float(row["Close"])
candle_color = (
bullish_color
if close_price >= open_price
else bearish_color
)
ax.vlines(
x,
low_price,
high_price,
color=candle_color,
linewidth=1.0,
)
body_bottom = min(open_price, close_price)
body_height = abs(close_price - open_price)
if body_height == 0:
body_height = max(high_price - low_price, 0.01) * 0.02
ax.add_patch(
Rectangle(
(x - body_width / 2.0, body_bottom),
body_width,
body_height,
facecolor=candle_color,
edgecolor=candle_color,
linewidth=0.8,
)
)
# ------------------------------------------------------------
# 8. Deterministic self-test
# ------------------------------------------------------------
toy_close = [100.0, 101.0, 103.0, 102.0, 106.0, 108.0]
toy_open = [99.0, 100.0, 102.0, 104.0, 103.0, 107.0]
toy_close_fwd_2 = forward_close_return(toy_close, horizon=2)
expected_close_fwd_2_index_1 = 102.0 / 101.0 - 1.0
assert abs(
toy_close_fwd_2[1] - expected_close_fwd_2_index_1
) < 1e-12
toy_open_fwd_2 = next_open_forward_return(toy_open, horizon=2)
# Signal index 1:
# entry = Open index 2 = 102
# exit = Open index 4 = 103
expected_open_fwd_2_index_1 = 103.0 / 102.0 - 1.0
assert abs(
toy_open_fwd_2[1] - expected_open_fwd_2_index_1
) < 1e-12
assert toy_open_fwd_2[-3:] == [None, None, None]
toy_summary = summarize_returns([0.10, -0.05, 0.02, None])
assert toy_summary["Count"] == 3
assert abs(toy_summary["Mean"] - (0.10 - 0.05 + 0.02) / 3.0) < 1e-12
assert abs(toy_summary["Positive Rate"] - 2.0 / 3.0) < 1e-12
print("Self-test")
print("=========")
print()
print(
"2-bar Close forward return at toy index 1:",
f"{toy_close_fwd_2[1]:.6f}",
)
print(
"2-bar executable Open return at toy index 1:",
f"{toy_open_fwd_2[1]:.6f}",
)
print()
print("Self-test: PASS")
print()
# ------------------------------------------------------------
# 9. Download market data
# ------------------------------------------------------------
today = date.today()
start_date = (today - timedelta(days=1800)).strftime("%Y-%m-%d")
end_date = today.strftime("%Y-%m-%d")
df = fdr.DataReader(symbol, start_date, end_date)
df = df.tail(recent_trading_days).copy()
if len(df) < sma_period + max(forward_horizons) + 10:
raise ValueError("Not enough market data.")
# ------------------------------------------------------------
# 10. Rebuild the Phase 4-01 event
# ------------------------------------------------------------
close_values = [float(v) for v in df["Close"]]
open_values = [float(v) for v in df["Open"]]
sma_values = simple_moving_average(close_values, period=sma_period)
state = above_sma_state(close_values, sma_values)
cross_above, cross_below = crossover_events(state)
df["SMA"] = sma_values
df["Above SMA"] = state
df["Cross Above"] = cross_above
df["Cross Below"] = cross_below
# ------------------------------------------------------------
# 11. Add forward-return labels
# ------------------------------------------------------------
for horizon in forward_horizons:
df[f"Close Fwd {horizon}"] = forward_close_return(
close_values,
horizon=horizon,
)
df[f"Open Fwd {horizon}"] = next_open_forward_return(
open_values,
horizon=horizon,
)
# ------------------------------------------------------------
# 12. Build Cross Above event table
# ------------------------------------------------------------
event_df = df[df["Cross Above"]].copy()
event_columns = ["Open", "High", "Low", "Close", "SMA"]
for horizon in forward_horizons:
event_columns.append(f"Close Fwd {horizon}")
event_columns.append(f"Open Fwd {horizon}")
event_output = event_df[event_columns].copy()
event_csv = SCRIPT_DIR / "sma20_cross_above_forward_returns.csv"
event_output.to_csv(
event_csv,
index_label="Signal Date",
)
# ------------------------------------------------------------
# 13. Signal group vs unconditional baseline
# ------------------------------------------------------------
summary_rows = []
for horizon in forward_horizons:
column_name = f"Open Fwd {horizon}"
signal_values = [
value if pd.notna(value) else None
for value in event_df[column_name]
]
baseline_values = [
value if pd.notna(value) else None
for value in df[column_name]
]
signal_summary = summarize_returns(signal_values)
baseline_summary = summarize_returns(baseline_values)
summary_rows.append(
{
"Horizon": horizon,
"Group": "Cross Above",
**signal_summary,
}
)
summary_rows.append(
{
"Horizon": horizon,
"Group": "All Eligible Bars",
**baseline_summary,
}
)
summary_df = pd.DataFrame(summary_rows)
summary_csv = SCRIPT_DIR / "forward_return_summary.csv"
summary_df.to_csv(summary_csv, index=False)
# ------------------------------------------------------------
# 14. Print descriptive comparison
# ------------------------------------------------------------
print("Forward-return comparison")
print("=========================")
print()
print("Signal: SMA20 Cross Above")
print("Execution label: Open(t+1) -> Open(t+1+h)")
print()
display_df = summary_df.copy()
for column_name in ["Mean", "Median", "Positive Rate"]:
display_df[column_name] = 100.0 * display_df[column_name]
print(display_df.to_string(index=False))
print()
print("Important:")
print(
"These are descriptive historical summaries, "
"not proof of predictive edge."
)
print(
"Forward-return windows can overlap, so observations "
"are not automatically independent."
)
# ------------------------------------------------------------
# 15. Event verification chart
# ------------------------------------------------------------
plot_df = df.tail(140).copy()
x_values = list(range(len(plot_df)))
fig, ax = plt.subplots(figsize=(12, 8))
draw_candlesticks(ax, plot_df)
ax.plot(
x_values,
plot_df["SMA"],
color=sma_color,
linewidth=1.6,
label=f"SMA {sma_period}",
)
signal_x = [
i
for i, value in enumerate(plot_df["Cross Above"])
if bool(value)
]
signal_y = [
float(plot_df.iloc[i]["Low"])
for i in signal_x
]
if signal_x:
ax.scatter(
signal_x,
signal_y,
marker="^",
s=70,
color=bullish_color,
label="Cross Above signal",
zorder=4,
)
ax.set_title(
f"{symbol} — SMA20 Cross Above Forward-Return Events"
)
ax.set_ylabel("Price")
ax.grid(axis="y", alpha=0.20)
ax.legend()
step = max(1, len(plot_df) // 8)
positions = list(range(0, len(plot_df), step))
labels = [
plot_df.index[i].strftime("%Y-%m-%d")
for i in positions
]
ax.set_xticks(positions)
ax.set_xticklabels(labels, rotation=35, ha="right")
fig.subplots_adjust(
left=0.09,
right=0.98,
top=0.93,
bottom=0.15,
)
event_chart_file = SCRIPT_DIR / "forward_return_events_aapl.png"
fig.savefig(event_chart_file, dpi=140)
plt.close(fig)
# ------------------------------------------------------------
# 16. Mean forward-return comparison chart
# ------------------------------------------------------------
signal_means = []
baseline_means = []
for horizon in forward_horizons:
signal_row = summary_df[
(summary_df["Horizon"] == horizon)
& (summary_df["Group"] == "Cross Above")
].iloc[0]
baseline_row = summary_df[
(summary_df["Horizon"] == horizon)
& (summary_df["Group"] == "All Eligible Bars")
].iloc[0]
signal_means.append(100.0 * float(signal_row["Mean"]))
baseline_means.append(100.0 * float(baseline_row["Mean"]))
x = list(range(len(forward_horizons)))
bar_width = 0.36
fig, ax = plt.subplots(figsize=(10, 6))
ax.bar(
[value - bar_width / 2.0 for value in x],
signal_means,
width=bar_width,
label="Cross Above",
)
ax.bar(
[value + bar_width / 2.0 for value in x],
baseline_means,
width=bar_width,
label="All Eligible Bars",
)
ax.axhline(0.0, linewidth=1.0)
ax.set_xticks(x)
ax.set_xticklabels([f"{h}-bar" for h in forward_horizons])
ax.set_ylabel("Mean Forward Return (%)")
ax.set_title("SMA20 Cross Above vs Unconditional Baseline")
ax.grid(axis="y", alpha=0.20)
ax.legend()
fig.subplots_adjust(
left=0.12,
right=0.97,
top=0.90,
bottom=0.13,
)
comparison_chart_file = (
SCRIPT_DIR
/ "forward_return_baseline_comparison.png"
)
fig.savefig(comparison_chart_file, dpi=140)
plt.close(fig)
# ------------------------------------------------------------
# 17. Finish
# ------------------------------------------------------------
print()
print("Files saved:")
print(event_csv)
print(summary_csv)
print(event_chart_file)
print(comparison_chart_file)
24. Run the Program
python phase4_02_forward_return.py
First confirm:
Self-test: PASS
The script then saves:
sma20_cross_above_forward_returns.csv
forward_return_summary.csv
forward_return_events_aapl.png
forward_return_baseline_comparison.png
25. Freeze the Phase 4-02 Research Card
Signal
SMA20 Cross Above
Signal Time
after Close(t)
Execution Entry
Open(t+1)
Horizons
1 / 5 / 20 bars
Executable Label
Open(t+1)
→ Open(t+1+h)
Signal Group
Cross Above bars
Baseline
All eligible bars
Statistics
Count
Mean
Median
Positive Rate
Status
Descriptive only
Check Your Understanding
- A forward return is a future outcome measured from a defined reference time.
- Future returns belong on the outcome side of the experiment, not inside today's signal.
- Close-to-Close forward return is useful descriptively but is not automatically an executable return for a Close-based signal.
- The Phase 4 execution convention uses next Open as the first possible simple entry price.
- The signal group and baseline must use the same return definition.
- Mean, median, positive rate, and count describe different aspects of the outcome distribution.
- The last rows become unavailable when there are not enough future bars.
- Overlapping forward-return windows mean observations may not be independent.
- A better historical average than the baseline is interesting but is not yet proof of a robust edge.
What You Just Learned
signal at t
↓
freeze information
available at t
↓
move forward
↓
1 / 5 / 20 bars
↓
future return label
↓
Signal Group
vs
Baseline Group
↓
descriptive comparison
not yet:
statistical proof
or full backtest
Forward return turns “what happened next?” into a measurable outcome, but a meaningful test requires the signal, execution timing, horizon, and baseline to be defined before interpreting the result.
Where Do We Go Next?
We can now observe a difference between the signal group and a baseline.
The next question is:
Is the difference
large enough and stable enough
to be meaningful?
That leads to distribution, uncertainty, sampling variation, and more careful comparison.
Sources and Further Reading
- pandas DataFrame.shift documentation — useful for aligning present rows with past or future observations.
- scikit-learn TimeSeriesSplit documentation — reinforces the broader principle that time-series evaluation must preserve chronology.