Phase 4-04 showed something easy to miss: many five-bar forward-return rows can share the same future market movement.
many rows
≠
many independent observations
That changes the next question.
We are no longer asking only:
Was the Signal Mean
higher than the Baseline Mean?
We now ask:
How uncertain is
that difference?
1. Keep the Research Design Frozen
Nothing about the trading idea changes.
Signal
SMA20 Cross Above
Signal Time
after Close(t)
Outcome
5-bar next-Open forward return
Entry
Open(t+1)
Exit
Open(t+6)
Baseline
All Eligible Bars
Phase 4-05 adds only one new layer:
uncertainty
2. Start with One Real Forward-Return Observation
Before thinking about bootstrap statistics, first remember what one row of the research table actually means.
The Python program selects a real AAPL SMA20 Cross Above signal and draws the candles around it.
Signal
known after Close(t)
Entry
Open(t+1)
Exit
Open(t+6)
One Observation
=
Open(t+6)
---------
Open(t+1)
- 1
Green candles are bullish. Red candles are bearish.
The shaded region is the exact five-bar future window that becomes one forward-return observation.
3. Many Observations Become One Research Number
Repeat the same forward-return calculation across all eligible rows.
Some rows are SMA20 Cross Above events. Most rows are not.
Eligible Row 1
Signal? False
Forward Return +0.8%
Eligible Row 2
Signal? True
Forward Return +2.1%
Eligible Row 3
Signal? False
Forward Return -0.4%
...
From these rows we calculate:
Signal Mean
-
Baseline Mean
That difference is the single statistic studied in this lesson.
4. Work Through a Tiny Example by Hand
Row 1
Signal = False
Return = +1%
Row 2
Signal = True
Return = +3%
Row 3
Signal = False
Return = -1%
Row 4
Signal = True
Return = +2%
Signal mean:
(3% + 2%) / 2
= 2.5%
Baseline mean:
(1% + 3% - 1% + 2%) / 4
= 1.25%
Therefore:
Signal Mean
-
Baseline Mean
=
2.5% - 1.25%
=
+1.25 percentage points
5. A Point Estimate Is Only One Answer
Suppose the real AAPL data gives:
Signal - Baseline
=
+0.20 percentage points
That is the answer from the historical sample we happened to observe.
But how stable is it?
Could a slightly different
sample of market history give:
-0.30?
+0.05?
+0.60?
A point estimate alone cannot answer that.
6. Bootstrap Means “Rebuild the Sample and Recalculate”
A bootstrap creates many alternative samples from the observations we already have.
Sampling is done with replacement, so an observation may appear more than once.
Original
A B C D
Bootstrap Sample 1
B B D A
Bootstrap Sample 2
D A D C
For every bootstrap sample, calculate the same research statistic again:
Signal Mean
-
Baseline Mean
7. The Bootstrap Distribution Is Not a Return Distribution
This distinction is important.
Phase 4-03
distribution of
individual forward returns
Phase 4-05
distribution of
recalculated
Signal - Baseline
mean differences
Phase 4-05 is studying uncertainty in the estimate, not the shape of individual market returns.
8. Why Not Randomly Shuffle Individual Rows?
A basic introductory bootstrap often treats rows as if they were independent.
pick row
pick row
pick row
pick row
But Phase 4-04 showed that adjacent five-bar forward returns can overlap.
They also belong to a time series with local market regimes.
Randomly picking isolated rows destroys that local order.
9. Keep the IID Row Bootstrap Only as a Comparison
The program still calculates an ordinary row bootstrap.
IID Row Bootstrap
— Illustrative
IID means independent and identically distributed.
We do not assume that condition is true here. The IID result is included so we can see what changes when local time order is ignored.
10. Moving-Block Bootstrap Keeps Nearby Rows Together
Instead of choosing isolated rows, choose a consecutive block of rows.
Original Time Order
0 1 2 3 4 5 6 7 8 9 ...
Sample One Block
21 22 23 ... 40
Sample Another Block
301 302 303 ... 320
Join enough blocks to rebuild a sample approximately the same size as the original sequence.
11. See the Block Sampling Directly
The program creates:
moving_block_bootstrap_mechanics.png
The first horizontal line represents the original eligible observations in time order.
Each line below it shows one contiguous block selected from that sequence.
The important visual idea is:
inside one sampled block
nearby observations
stay nearby
12. Why Does a Block Help?
A moving block preserves local ordering inside the block.
row 101
row 102
row 103
...
row 120
Those observations remain together when that block is selected.
This is more appropriate for our time-series problem than pretending each row is completely unrelated to its neighbors.
13. But a Block Bootstrap Is Not Perfect
When one sampled block ends and another begins, the original time relationship between those two blocks is broken.
Block A
...
end
↓ artificial join
Block B
start
...
Therefore:
Moving-Block Bootstrap
≠
perfect reconstruction
of the real market
14. Block Length Is Part of the Model
This lesson uses:
block_length = 20
Twenty bars are used as a clear teaching choice, roughly comparable to one trading month.
We are not claiming that 20 is statistically optimal.
Short block
→ preserves less dependence
Long block
→ preserves longer local structure
but gives fewer distinct blocks
15. Preserve the Signal Flag and Return Together
Each eligible observation is stored as:
(
is_signal,
forward_return
)
If a row is resampled, its signal label and historical return move together.
16. One Bootstrap Repetition, Step by Step
1.
Start with the
time-ordered eligible rows
2.
Choose contiguous blocks
3.
Join blocks until
sample size ≈ original size
4.
Find all signal rows
inside that new sample
5.
Calculate Signal Mean
6.
Calculate Baseline Mean
7.
Store:
Signal Mean
-
Baseline Mean
That produces one bootstrap estimate.
17. Repeat 5,000 Times
bootstrap_reps = 5000
After 5,000 repetitions we no longer have one estimate.
We have a distribution of estimates:
estimate 1
estimate 2
estimate 3
...
estimate 5000
18. See the Bootstrap Distribution
The program creates:
moving_block_bootstrap_distribution.png
The histogram shows how frequently different Signal-minus-Baseline estimates appeared across the 5,000 resamples.
Reference lines mark:
zero difference
observed difference
95% lower boundary
95% upper boundary
19. Build a 95% Percentile Interval
Sort the 5,000 bootstrap estimates.
A simple 95% percentile interval keeps the middle 95%.
lower boundary
=
2.5th percentile
upper boundary
=
97.5th percentile
The program implements the percentile calculation directly, so no new statistical package is required.
20. Read the Interval Visually
Suppose:
Observed Difference
= +0.20 percentage points
95% Bootstrap Interval
= -0.30 to +0.75
Zero is inside the interval.
Under this resampling design, plausible estimates include:
negative difference
zero difference
positive difference
The positive point estimate is therefore still uncertain.
21. What If the Interval Does Not Cross Zero?
95% Bootstrap Interval
+0.10
to
+0.80 percentage points
Under that resampling design, the result is stronger than a positive point estimate alone.
But it still does not mean:
future profit guaranteed
trading strategy proven
out-of-sample robust
profitable after costs
22. Compare IID and Moving-Block Intervals
The program creates:
bootstrap_interval_comparison.png
Both methods start from the same observed point estimate.
What changes is the resampling assumption.
IID Row Bootstrap
individual rows
picked separately
Moving-Block Bootstrap
local sequences
picked together
23. A Narrower Interval Is Not Automatically Better
A narrow interval may look attractive.
But:
narrow interval
from an unrealistic
independence assumption
≠
better evidence
The uncertainty model should respect the data structure as much as the current research stage allows.
24. What This Lesson Still Does Not Solve
optimal block length
all forms of serial dependence
market regime changes
multiple testing
parameter selection bias
out-of-sample validation
transaction costs
position accounting
A bootstrap interval is one research layer, not the final strategy verdict.
25. The Complete Python Program
This lesson introduces no new external package.
It reuses:
FinanceDataReader
pandas
matplotlib
and Python's built-in:
random
statistics
Save as:
phase4_05_bootstrap_confidence_interval_v1_1.py
from pathlib import Path
from datetime import date, timedelta
from statistics import mean, stdev
import os
import random
import FinanceDataReader as fdr
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
import pandas as pd
# ============================================================
# Phase 4-05 — Bootstrap Confidence Intervals
#
# Learning flow:
#
# Candles
# ↓
# One forward return
# ↓
# Many eligible observations
# ↓
# Signal Mean - Baseline Mean
# ↓
# Resample
# ↓
# Recalculate many times
# ↓
# Bootstrap distribution
# ↓
# 95% bootstrap interval
#
# Phase 4-04 taught us that adjacent forward returns can
# overlap. Therefore this lesson compares:
#
# 1. IID row bootstrap — illustrative only
# 2. Moving-block bootstrap — time-series diagnostic
# ============================================================
# ------------------------------------------------------------
# 1. Settings
# ------------------------------------------------------------
symbol = "AAPL"
recent_trading_days = 800
sma_period = 20
forward_horizon = 5
bootstrap_reps = 5000
confidence_level = 0.95
# Teaching choice only.
# This is NOT claimed to be the statistically optimal
# block length.
block_length = 20
random_seed = 20260905
SCRIPT_DIR = Path(__file__).resolve().parent
os.chdir(SCRIPT_DIR)
# ------------------------------------------------------------
# 2. SMA and event functions
# ------------------------------------------------------------
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,
)
# ------------------------------------------------------------
# 3. Forward return
# ------------------------------------------------------------
def next_open_forward_return(
open_values,
horizon,
):
"""
Signal is known after Close(t).
Entry:
Open(t+1)
Exit:
Open(t+1+horizon)
"""
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(
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
# ------------------------------------------------------------
# 4. Research statistic
# ------------------------------------------------------------
def signal_minus_baseline_mean(
records,
):
"""
Each record is:
(
is_signal,
forward_return,
)
Baseline:
all eligible rows
Signal:
rows where is_signal is True
Statistic:
Signal Mean - Baseline Mean
"""
if not records:
return None
baseline_values = [
float(return_value)
for _, return_value
in records
]
signal_values = [
float(return_value)
for is_signal, return_value
in records
if bool(is_signal)
]
if not signal_values:
return None
return (
mean(signal_values)
- mean(baseline_values)
)
# ------------------------------------------------------------
# 5. Percentiles
# ------------------------------------------------------------
def percentile(
values,
probability,
):
if not (
0.0
<= probability
<= 1.0
):
raise ValueError(
"probability must be "
"between 0 and 1"
)
clean_values = sorted(
float(value)
for value in values
)
if not clean_values:
raise ValueError(
"values cannot be empty"
)
if len(clean_values) == 1:
return clean_values[0]
position = (
probability
* (len(clean_values) - 1)
)
lower_index = int(position)
upper_index = min(
lower_index + 1,
len(clean_values) - 1,
)
weight = (
position
- lower_index
)
return (
clean_values[lower_index]
* (1.0 - weight)
+
clean_values[upper_index]
* weight
)
def percentile_interval(
values,
confidence_level,
):
alpha = (
1.0
- confidence_level
)
lower_probability = (
alpha / 2.0
)
upper_probability = (
1.0
- alpha / 2.0
)
return (
percentile(
values,
lower_probability,
),
percentile(
values,
upper_probability,
),
)
# ------------------------------------------------------------
# 6. IID row bootstrap
# ------------------------------------------------------------
def iid_bootstrap_distribution(
records,
reps,
rng,
):
"""
Educational comparison only.
This resamples individual rows.
Phase 4-04 showed why treating every
time-series row as independent is
questionable.
"""
n = len(records)
if n == 0:
raise ValueError(
"records cannot be empty"
)
results = []
while len(results) < reps:
sample = [
records[
rng.randrange(n)
]
for _ in range(n)
]
statistic = (
signal_minus_baseline_mean(
sample
)
)
if statistic is not None:
results.append(
statistic
)
return results
# ------------------------------------------------------------
# 7. Moving-block bootstrap
# ------------------------------------------------------------
def moving_block_bootstrap_indices(
n,
block_length,
rng,
):
"""
Return source row indices used to build
one moving-block bootstrap sample.
Example:
original rows:
0 1 2 3 4 5 6 ...
choose block:
21 ... 40
choose another:
301 ... 320
concatenate until sample length = n
"""
if n <= 0:
raise ValueError(
"n must be positive"
)
if (
block_length <= 0
or block_length > n
):
raise ValueError(
"invalid block_length"
)
max_start = (
n - block_length
)
selected_indices = []
block_starts = []
while len(
selected_indices
) < n:
start = rng.randint(
0,
max_start,
)
block_starts.append(
start
)
selected_indices.extend(
range(
start,
start + block_length,
)
)
return (
selected_indices[:n],
block_starts,
)
def moving_block_bootstrap_sample(
records,
block_length,
rng,
):
selected_indices, _ = (
moving_block_bootstrap_indices(
len(records),
block_length,
rng,
)
)
return [
records[index_value]
for index_value
in selected_indices
]
def moving_block_bootstrap_distribution(
records,
reps,
block_length,
rng,
):
results = []
while len(results) < reps:
sample = (
moving_block_bootstrap_sample(
records,
block_length,
rng,
)
)
statistic = (
signal_minus_baseline_mean(
sample
)
)
if statistic is not None:
results.append(
statistic
)
return results
# ------------------------------------------------------------
# 8. Bootstrap summary
# ------------------------------------------------------------
def summarize_bootstrap(
values,
confidence_level,
):
lower, upper = (
percentile_interval(
values,
confidence_level,
)
)
return {
"Replicates": len(values),
"Bootstrap Mean":
mean(values),
"Bootstrap SD":
stdev(values),
"CI Lower":
lower,
"CI Upper":
upper,
"Includes Zero":
lower <= 0.0 <= upper,
}
# ------------------------------------------------------------
# 9. Candlestick drawing
# ------------------------------------------------------------
def draw_candlesticks(
ax,
market_df,
body_width=0.62,
):
"""
Bullish:
seagreen
Bearish:
firebrick
"""
bullish_color = "seagreen"
bearish_color = "firebrick"
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"]
)
bullish = (
close_price
>= open_price
)
candle_color = (
bullish_color
if bullish
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=1.0,
alpha=0.90,
)
)
# ------------------------------------------------------------
# 10. Deterministic self-test
# ------------------------------------------------------------
toy_records = [
(False, 0.01),
(True, 0.03),
(False, -0.01),
(True, 0.02),
]
toy_statistic = (
signal_minus_baseline_mean(
toy_records
)
)
# Signal mean:
# (0.03 + 0.02) / 2
# = 0.025
#
# Baseline mean:
# (0.01 + 0.03 - 0.01 + 0.02) / 4
# = 0.0125
#
# Difference:
# 0.025 - 0.0125
# = 0.0125
assert abs(
toy_statistic
- 0.0125
) < 1e-12
assert abs(
percentile(
[0, 1, 2, 3, 4],
0.50,
)
- 2.0
) < 1e-12
toy_rng = random.Random(
123
)
toy_indices, toy_starts = (
moving_block_bootstrap_indices(
n=12,
block_length=3,
rng=toy_rng,
)
)
assert len(
toy_indices
) == 12
assert len(
toy_starts
) >= 4
print("Self-test")
print("=========")
print(
"Toy Signal - Baseline:",
toy_statistic,
)
print(
"Toy block starts:",
toy_starts,
)
print("Self-test: PASS")
print()
# ------------------------------------------------------------
# 11. 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()
minimum_rows = (
sma_period
+ forward_horizon
+ block_length
+ 20
)
if len(df) < minimum_rows:
raise ValueError(
"Not enough market data."
)
# ------------------------------------------------------------
# 12. Rebuild the frozen signal
# ------------------------------------------------------------
close_values = [
float(value)
for value in df["Close"]
]
open_values = [
float(value)
for value in df["Open"]
]
sma_values = (
simple_moving_average(
close_values,
sma_period,
)
)
state = above_sma_state(
close_values,
sma_values,
)
cross_above, cross_below = (
crossover_events(
state
)
)
df["SMA"] = sma_values
df["Cross Above"] = (
cross_above
)
df["Cross Below"] = (
cross_below
)
# ------------------------------------------------------------
# 13. Rebuild the 5-bar outcome
# ------------------------------------------------------------
return_column = (
f"Open Fwd {forward_horizon}"
)
df[return_column] = (
next_open_forward_return(
open_values,
forward_horizon,
)
)
# ------------------------------------------------------------
# 14. Build one time-ordered eligible sequence
# ------------------------------------------------------------
eligible_records = []
eligible_source_indices = []
for i in range(len(df)):
return_value = (
df.iloc[i][
return_column
]
)
if pd.isna(
return_value
):
continue
eligible_records.append(
(
bool(
df.iloc[i][
"Cross Above"
]
),
float(
return_value
),
)
)
eligible_source_indices.append(
i
)
signal_count = sum(
is_signal
for is_signal, _
in eligible_records
)
if signal_count < 2:
raise ValueError(
"Not enough signal events "
"for bootstrap."
)
point_estimate = (
signal_minus_baseline_mean(
eligible_records
)
)
# ------------------------------------------------------------
# 15. Visual 1 — where one observation comes from
# ------------------------------------------------------------
visual_signal_candidates = [
i
for i in range(len(df))
if (
bool(
df.iloc[i][
"Cross Above"
]
)
and pd.notna(
df.iloc[i][
return_column
]
)
and (
i
+ forward_horizon
+ 1
< len(df)
)
)
]
source_candlestick_file = (
SCRIPT_DIR
/ "bootstrap_source_candlestick.png"
)
source_example_file = (
SCRIPT_DIR
/ "bootstrap_source_example.csv"
)
if visual_signal_candidates:
example_index = (
visual_signal_candidates[-1]
)
entry_index = (
example_index + 1
)
exit_index = (
entry_index
+ forward_horizon
)
example_return = (
float(
df.iloc[
exit_index
]["Open"]
)
/
float(
df.iloc[
entry_index
]["Open"]
)
- 1.0
)
pd.DataFrame(
[
{
"Signal Date":
df.index[
example_index
],
"Entry Date":
df.index[
entry_index
],
"Entry Open":
float(
df.iloc[
entry_index
]["Open"]
),
"Exit Date":
df.index[
exit_index
],
"Exit Open":
float(
df.iloc[
exit_index
]["Open"]
),
"Forward Return":
example_return,
}
]
).to_csv(
source_example_file,
index=False,
)
plot_start = max(
0,
example_index - 10,
)
plot_end = min(
len(df),
exit_index + 8,
)
plot_df = df.iloc[
plot_start:plot_end
].copy()
signal_x = (
example_index
- plot_start
)
entry_x = (
entry_index
- plot_start
)
exit_x = (
exit_index
- plot_start
)
fig, ax = plt.subplots(
figsize=(12, 7)
)
draw_candlesticks(
ax,
plot_df,
)
ax.plot(
list(
range(
len(plot_df)
)
),
plot_df["SMA"],
linewidth=1.4,
label=f"SMA {sma_period}",
)
ax.axvline(
signal_x,
linewidth=1.5,
label=(
"Signal known "
"after Close(t)"
),
)
ax.axvspan(
entry_x,
exit_x,
alpha=0.12,
hatch="//",
label=(
"One observation: "
"Open(t+1) → Open(t+6)"
),
)
ax.axvline(
entry_x,
linestyle="--",
linewidth=1.1,
)
ax.axvline(
exit_x,
linestyle="--",
linewidth=1.1,
)
ax.set_title(
f"{symbol} — One Forward-Return "
"Observation Used by the Bootstrap"
)
ax.set_ylabel(
"Price"
)
ax.grid(
axis="y",
alpha=0.20,
)
step = max(
1,
len(plot_df) // 8,
)
tick_positions = list(
range(
0,
len(plot_df),
step,
)
)
tick_labels = [
plot_df.index[i].strftime(
"%Y-%m-%d"
)
for i
in tick_positions
]
ax.set_xticks(
tick_positions
)
ax.set_xticklabels(
tick_labels,
rotation=35,
ha="right",
)
ax.legend()
fig.subplots_adjust(
left=0.09,
right=0.98,
top=0.90,
bottom=0.18,
)
fig.savefig(
source_candlestick_file,
dpi=140,
)
plt.close(fig)
# ------------------------------------------------------------
# 16. Visual 2 — what one moving-block sample does
# ------------------------------------------------------------
example_rng = random.Random(
random_seed
)
example_indices, example_block_starts = (
moving_block_bootstrap_indices(
len(
eligible_records
),
block_length,
example_rng,
)
)
block_example_rows = []
for block_number, start in enumerate(
example_block_starts[:6],
start=1,
):
block_example_rows.append(
{
"Block":
block_number,
"Start Eligible Row":
start,
"End Eligible Row":
start
+ block_length
- 1,
}
)
block_example_df = pd.DataFrame(
block_example_rows
)
block_example_file = (
SCRIPT_DIR
/ "moving_block_bootstrap_example.csv"
)
block_example_df.to_csv(
block_example_file,
index=False,
)
block_map_file = (
SCRIPT_DIR
/ "moving_block_bootstrap_mechanics.png"
)
fig, ax = plt.subplots(
figsize=(11, 6.5)
)
n_eligible = len(
eligible_records
)
ax.hlines(
y=0,
xmin=0,
xmax=n_eligible - 1,
linewidth=3.0,
)
ax.text(
0,
0.20,
"Original eligible time order",
va="bottom",
)
display_blocks = (
block_example_df.head(5)
)
for row_number, row in enumerate(
display_blocks.itertuples(
index=False
),
start=1,
):
y_value = row_number
start_value = int(
getattr(
row,
"_1",
)
) if False else int(
row[1]
)
end_value = int(
row[2]
)
ax.hlines(
y=y_value,
xmin=start_value,
xmax=end_value,
linewidth=8.0,
)
ax.text(
start_value,
y_value + 0.16,
f"Block {row_number}: "
f"{start_value}–{end_value}",
va="bottom",
)
ax.set_xlim(
-5,
n_eligible + 5,
)
ax.set_ylim(
-0.8,
len(display_blocks) + 1.0,
)
ax.set_yticks(
[0]
+ list(
range(
1,
len(display_blocks) + 1,
)
)
)
ax.set_yticklabels(
["Original"]
+ [
f"Sampled Block {i}"
for i in range(
1,
len(display_blocks) + 1,
)
]
)
ax.set_xlabel(
"Position in the time-ordered eligible record sequence"
)
ax.set_title(
f"Moving-Block Bootstrap Mechanics "
f"(Block Length = {block_length})"
)
ax.grid(
axis="x",
alpha=0.20,
)
fig.subplots_adjust(
left=0.21,
right=0.98,
top=0.90,
bottom=0.14,
)
fig.savefig(
block_map_file,
dpi=140,
)
plt.close(fig)
# ------------------------------------------------------------
# 17. Bootstrap distributions
# ------------------------------------------------------------
iid_distribution = (
iid_bootstrap_distribution(
eligible_records,
reps=bootstrap_reps,
rng=random.Random(
random_seed
),
)
)
block_distribution = (
moving_block_bootstrap_distribution(
eligible_records,
reps=bootstrap_reps,
block_length=block_length,
rng=random.Random(
random_seed
),
)
)
iid_summary = (
summarize_bootstrap(
iid_distribution,
confidence_level,
)
)
block_summary = (
summarize_bootstrap(
block_distribution,
confidence_level,
)
)
# ------------------------------------------------------------
# 18. Save numeric results
# ------------------------------------------------------------
summary_rows = [
{
"Method":
"IID Row Bootstrap — Illustrative",
"Point Estimate":
point_estimate,
"Block Length":
None,
**iid_summary,
},
{
"Method":
"Moving-Block Bootstrap — Diagnostic",
"Point Estimate":
point_estimate,
"Block Length":
block_length,
**block_summary,
},
]
summary_df = pd.DataFrame(
summary_rows
)
summary_file = (
SCRIPT_DIR
/ "bootstrap_confidence_interval_summary.csv"
)
summary_df.to_csv(
summary_file,
index=False,
)
distribution_df = pd.DataFrame(
{
"IID Row Bootstrap":
iid_distribution,
"Moving-Block Bootstrap":
block_distribution,
}
)
distribution_file = (
SCRIPT_DIR
/ "bootstrap_mean_difference_distribution.csv"
)
distribution_df.to_csv(
distribution_file,
index=False,
)
# ------------------------------------------------------------
# 19. Print a human-readable summary
# ------------------------------------------------------------
print(
"Observed research result"
)
print(
"========================"
)
print()
print(
f"Symbol: {symbol}"
)
print(
f"Signal count: "
f"{signal_count}"
)
print(
"Eligible baseline rows:",
len(
eligible_records
),
)
print()
print(
"Point estimate:"
)
print(
"Signal Mean "
"- Baseline Mean"
)
print(
f"{100.0 * point_estimate:.4f} "
"percentage points"
)
print()
display_df = (
summary_df.copy()
)
for column_name in [
"Point Estimate",
"Bootstrap Mean",
"Bootstrap SD",
"CI Lower",
"CI Upper",
]:
display_df[
column_name
] = (
100.0
* display_df[
column_name
]
)
print(
display_df.to_string(
index=False
)
)
print()
print("Interpretation guardrails")
print("=========================")
print(
"IID row bootstrap is "
"illustrative only."
)
print(
"Moving-block bootstrap "
"preserves local chunks, "
"but depends on block length."
)
print(
"A bootstrap interval is "
"conditional on this "
"resampling design."
)
print(
"It is not proof of a "
"durable trading edge."
)
# ------------------------------------------------------------
# 20. Visual 3 — bootstrap distribution
# ------------------------------------------------------------
moving_histogram_file = (
SCRIPT_DIR
/ "moving_block_bootstrap_distribution.png"
)
block_percent = [
100.0 * value
for value
in block_distribution
]
point_percent = (
100.0
* point_estimate
)
block_lower = (
100.0
* block_summary[
"CI Lower"
]
)
block_upper = (
100.0
* block_summary[
"CI Upper"
]
)
fig, ax = plt.subplots(
figsize=(10, 6)
)
ax.hist(
block_percent,
bins=35,
)
ax.axvline(
0.0,
linewidth=1.2,
label="Zero difference",
)
ax.axvline(
point_percent,
linestyle="--",
linewidth=1.4,
label="Observed difference",
)
ax.axvline(
block_lower,
linestyle=":",
linewidth=1.4,
label="95% interval lower",
)
ax.axvline(
block_upper,
linestyle=":",
linewidth=1.4,
label="95% interval upper",
)
ax.set_title(
f"{symbol} — Moving-Block Bootstrap "
"of Signal Minus Baseline Mean"
)
ax.set_xlabel(
"Signal - Baseline Mean Return "
"(percentage points)"
)
ax.set_ylabel(
"Bootstrap Replicates"
)
ax.grid(
axis="y",
alpha=0.20,
)
ax.legend()
fig.subplots_adjust(
left=0.11,
right=0.97,
top=0.90,
bottom=0.14,
)
fig.savefig(
moving_histogram_file,
dpi=140,
)
plt.close(fig)
# ------------------------------------------------------------
# 21. Visual 4 — interval comparison
# ------------------------------------------------------------
interval_comparison_file = (
SCRIPT_DIR
/ "bootstrap_interval_comparison.png"
)
methods = [
"IID Row Bootstrap\nIllustrative",
"Moving-Block Bootstrap\nDiagnostic",
]
centers = [
point_percent,
point_percent,
]
lowers = [
100.0
* iid_summary[
"CI Lower"
],
100.0
* block_summary[
"CI Lower"
],
]
uppers = [
100.0
* iid_summary[
"CI Upper"
],
100.0
* block_summary[
"CI Upper"
],
]
lower_errors = [
centers[i]
- lowers[i]
for i in range(2)
]
upper_errors = [
uppers[i]
- centers[i]
for i in range(2)
]
fig, ax = plt.subplots(
figsize=(10, 5.5)
)
ax.errorbar(
centers,
[0, 1],
xerr=[
lower_errors,
upper_errors,
],
fmt="o",
capsize=6,
)
ax.axvline(
0.0,
linewidth=1.2,
)
ax.set_yticks(
[0, 1]
)
ax.set_yticklabels(
methods
)
ax.set_xlabel(
"Signal - Baseline Mean Return "
"(percentage points)"
)
ax.set_title(
f"{symbol} — 95% Bootstrap Interval Comparison"
)
ax.grid(
axis="x",
alpha=0.20,
)
fig.subplots_adjust(
left=0.28,
right=0.97,
top=0.88,
bottom=0.16,
)
fig.savefig(
interval_comparison_file,
dpi=140,
)
plt.close(fig)
# ------------------------------------------------------------
# 22. Finish
# ------------------------------------------------------------
print()
print("Files saved:")
print(summary_file)
print(distribution_file)
if visual_signal_candidates:
print(source_example_file)
print(source_candlestick_file)
print(block_example_file)
print(block_map_file)
print(moving_histogram_file)
print(interval_comparison_file)
26. Run the Program
python phase4_05_bootstrap_confidence_interval_v1_1.py
First confirm:
Self-test: PASS
Then inspect:
bootstrap_source_example.csv
bootstrap_source_candlestick.png
moving_block_bootstrap_example.csv
moving_block_bootstrap_mechanics.png
bootstrap_confidence_interval_summary.csv
bootstrap_mean_difference_distribution.csv
moving_block_bootstrap_distribution.png
bootstrap_interval_comparison.png
27. Research Checkpoint
Signal
SMA20 Cross Above
Outcome
5-bar next-Open
forward return
Baseline
All Eligible Bars
Statistic
Signal Mean
-
Baseline Mean
Problem from Phase 4-04
dependent observations
New Tool
bootstrap
Illustrative Method
IID row bootstrap
Time-Series Diagnostic
moving-block bootstrap
Bootstrap Repetitions
5000
Teaching Block Length
20 bars
Interval
95% percentile interval
Current Status
UNCERTAINTY ESTIMATE
Not Yet
position lifecycle
backtest
costs
out-of-sample validation
28. Check Your Understanding
- One forward-return observation comes from a specific future candle window.
- The research statistic is Signal Mean minus Baseline Mean.
- A point estimate does not show its uncertainty.
- A bootstrap repeatedly rebuilds a sample and recalculates the same statistic.
- A bootstrap distribution contains repeated statistic estimates, not individual market returns.
- IID row resampling destroys local time order.
- A moving-block bootstrap preserves contiguous observations inside each selected block.
- A block bootstrap still creates artificial joins between sampled blocks.
- Block length is part of the resampling model.
- A 95% percentile interval uses the 2.5th and 97.5th percentiles.
- An interval that excludes zero is stronger evidence than a point estimate alone, but it is not proof of a durable trading edge.
29. Change One Thing Yourself
Keep the signal, horizon, baseline, and bootstrap repetitions frozen.
Change only:
block_length = 20
Try:
10
20
40
Ask:
Does the interval widen?
Does it narrow?
Does zero move
inside or outside
the interval?
Does the conclusion
depend strongly
on block length?
Do not choose the block length that gives the most attractive result.
This is sensitivity analysis, not optimization.
30. What You Just Learned
Real Candles
↓
Forward Return
↓
Eligible Observations
↓
Signal - Baseline
Point Estimate
↓
Resample
↓
Recalculate
many times
↓
Bootstrap Distribution
↓
95% Interval
↓
Uncertainty
while remembering:
Time-Series Dependence
↓
Resampling Design Matters
Uncertainty is not something added after the result. It is part of the result.
31. Where Do We Go Next?
Phase 4 has now moved through:
Rule
↓
Forward Outcome
↓
Baseline
↓
Distribution
↓
Dependence
↓
Uncertainty
We still do not have an actual trading position.
Phase 4-06 will answer:
How do Entry and Exit events
become a position
that exists through time?
CASH
↓
ENTRY
↓
LONG
↓
EXIT
↓
CASH
Sources and Further Reading
- Bradley Efron — Second Thoughts on the Bootstrap — background on bootstrap reasoning, accuracy estimation, and limitations.
- arch documentation — Time-series Bootstraps — overview of stationary, circular-block, and moving-block bootstrap approaches.
- arch — MovingBlockBootstrap — reference documentation for fixed-length moving-block resampling. The package is not required in this lesson.