Test a Short-Term Momentum Hypothesis
Build a bias-aware Python backtest with a chronological holdout.
Introduction
30 Second Summary
A pattern can look convincing when it is tested on the same history that revealed it. The real test is whether it survives later data under changing conditions.
In this project, you will use Python to test whether the sign of one return predicts the sign of the next. The finished study contrasts a misleading perfect result with a time-correct test on later observations.
What You'll Build
You will see a three-panel chart reveal why a perfect backtest fails once the synthetic market changes.
By the end of this project, you'll have:
- A reproducible market simulation that produces the same 600 observations on every run.
- A look-ahead bias diagnostic where a perfect positive-period result collapses after you correct the signal timing.
- Cost-aware holdout evidence with a moving-block bootstrap interval. A three-panel chart visualizes the evidence. A concise report states the conclusion.
- Secret Mission: A cost-sensitivity sweep that reveals whether the holdout conclusion survives every illustrative turnover cost.
Are there any prerequisites?
Basic Python syntax is enough for this project. No previous quantitative research experience is required.
Before We Start
A useful research study begins with a clear hypothesis and a commitment to follow the evidence. Before you start building, decide what you are testing and agree to report the result honestly even if the hypothesis fails.
Prepare a Reproducible Research Workspace
Your hypothesis only matters if another researcher can recreate the conditions that produced the result. A mismatched Python version or package release can turn the same script into a different experiment.
In this step, you will prepare a Visual Studio Code workspace with Python 3.12 or newer.
A virtual environment will hold NumPy 2.5.4 plus Matplotlib 3.11.2.
In this step, get ready to:
- Verify Python 3.12 or newer on Windows.
- Prepare the signal-or-noise workspace with the Python extension.
- Confirm the pinned NumPy and Matplotlib imports inside tutorial_env.
Prepare the Windows workspace
NumPy 2.5.4 requires Python 3.12 or newer. Checking the interpreter first prevents an incompatible package installation later.
- Press the Windows key to open the search bar.
- Type PowerShell into the search bar.
- Press Enter to open PowerShell.
Before you run the check, what Python version do you expect this computer to report?
- Check the installed Python version by running this command:
py --version
What does this command do?
The py launcher finds the Windows Python installation. The version output tells you whether the pinned packages can run in this project.
✔️ I see Python 3.12 or higher
Your interpreter meets the project requirement. That removes the first source of reproducibility problems.
ⓧ I see an older Python version
The installed interpreter is too old for NumPy 2.5.4. Install a current Python 3 release before creating the project environment.
- Visit the official Python downloads page for Windows.
- Download the current Python 3 installer for Windows.
- Run the downloaded installer with its default installation options.
- Approve the Windows permission prompt if it appears.
- Reopen PowerShell after the installation finishes.
- Verify the updated Python version by running this command:
py --version
What does this command confirm?
The repeated check confirms that the Windows Python launcher now points to a compatible interpreter.
You should now see Python 3.12 or newer in the output.
ⓧ Command not found
Windows cannot find the Python launcher yet. Install Python before creating the research workspace.
- Visit the official Python downloads page for Windows.
- Download the current Python 3 installer for Windows.
- Run the downloaded installer with its default installation options.
- Approve the Windows permission prompt if it appears.
- Reopen PowerShell after the installation finishes.
- Check that the Python launcher is available by running this command:
py --version
What does this command confirm?
A version number confirms that Windows can now find the Python launcher.
You should see Python 3.12 or newer in the output.
- Press the Windows key to return to the search bar.
- Type Visual Studio Code into the search bar.
- Press Enter to open Visual Studio Code.
Why use a named workspace?
A dedicated workspace keeps the environment beside the scripts that depend on it. The signal-or-noise folder becomes the reproducible home for the entire study.
- Click File in the Visual Studio Code menu bar.
- Click Open Folder.
- Select your Desktop in the folder picker.
- Click New Folder in the folder picker.
- Enter signal-or-noise as the folder name.
- Press Enter to create the folder.
- Click Select Folder to open the workspace.
You should see signal-or-noise at the top of the Explorer sidebar. Visual Studio Code is now working inside the correct folder.
Create the isolated interpreter
The Python extension connects Visual Studio Code to your interpreter. The project environment keeps these package versions separate from every other Python project on your computer.
- Click the Extensions icon in the left Activity Bar.
- Type Python into the extensions search field.
- Select the Python extension published by Microsoft.
- Click Install if the extension is not already installed.
The extension card should now show that it is installed. Visual Studio Code can now discover Python interpreters in this workspace.
- Click Terminal in the Visual Studio Code menu bar.
- Click New Terminal.
- Open the terminal profile menu beside the new-terminal button.
- Select Command Prompt as the terminal profile.
- Create the project environment by running this command:
py -m venv tutorial_env
What does this command do?
The command creates a self-contained Python environment inside tutorial_env. Packages installed there stay isolated from the global interpreter.
- Confirm that a tutorial_env folder appears in the Explorer sidebar.
The new folder proves that Python created the local environment inside signal-or-noise.
Don't see tutorial_env?
- Confirm that the terminal path ends with signal-or-noise.
- Confirm that the earlier version check reported Python 3.12 or newer.
Still stuck? Help me troubleshoot why py -m venv tutorial_env did not create the folder in my Visual Studio Code workspace.
- Activate the project environment by running this command:
tutorial_env\Scripts\activate
What does activation change?
Activation points this terminal session at the Python interpreter inside tutorial_env. The next package installation goes into the project environment.
You should see (tutorial_env) at the start of the terminal prompt.
Don't see the environment name?
- Confirm that the active terminal profile is Command Prompt.
- Confirm that the terminal is inside the signal-or-noise folder.
Still stuck? Help me activate tutorial_env in the Visual Studio Code terminal on Windows.
The interpreter picker is a little fiddly because it can list every Python installation on your computer. Choose the entry associated with tutorial_env.
- Press Ctrl+Shift+P to open the Command Palette.
- Type Python: Select Interpreter into the Command Palette.
- Select Python: Select Interpreter from the results.
- Choose the interpreter whose path includes tutorial_env.
The Visual Studio Code Status Bar should now identify the interpreter from tutorial_env. Your editor and terminal now use the same isolated environment.
Verify the pinned package setup
The requirements.txt file records the exact package versions behind the experiment. This turns the environment into a recipe another researcher can recreate.
- Click the new-file icon in the Visual Studio Code Explorer sidebar.
- Enter requirements.txt as the file name.
- Press Enter to create the file.
- Pin the project packages by replacing the file contents with the code below:
numpy==2.5.4
matplotlib==3.11.2
What do these pins control?
- The first line fixes NumPy at 2.5.4.
- The second line fixes Matplotlib at 3.11.2.
- Save requirements.txt by pressing Ctrl+S.
✔️ Awesome, I've got everything!
Your package pins are saved in requirements.txt.
ⓧ I'd like to double check the full code
numpy==2.5.4
matplotlib==3.11.2
The first installation can take one or two minutes while the packages download. The terminal may pause briefly during that work.
- Install the pinned packages into tutorial_env by running this command:
py -m pip install -r requirements.txt
What does this command do?
The command asks pip to read requirements.txt. Pip installs both pinned packages into the active project environment.
- Wait for the terminal prompt to return after the installation finishes.
The terminal should return to a prompt beginning with (tutorial_env) without an installation error.
Package installation failed?
- Confirm that the terminal prompt begins with (tutorial_env).
- Confirm that requirements.txt contains both version pins exactly as shown.
- Confirm that the computer has an active internet connection.
Still stuck? Help me troubleshoot the pinned NumPy and Matplotlib installation in tutorial_env.
- Click the new-file icon in the Visual Studio Code Explorer sidebar.
- Enter check_setup.py as the file name.
- Press Enter to create the file.
- Create the import check by replacing the file contents with the code below:
import matplotlib.pyplot as plt
import numpy as np
print("NumPy module:", np.__name__)
print("Matplotlib pyplot module:", plt.__name__)
What does this setup check prove?
- The imports ask Python to load NumPy through np.
- The imports also load Matplotlib's plotting interface through plt.
- The two print calls expose each imported module's __name__ value.
- Save check_setup.py by pressing Ctrl+S.
✔️ Awesome, I've got everything!
Your setup check is saved in check_setup.py.
ⓧ I'd like to double check the full code
import matplotlib.pyplot as plt
import numpy as np
print("NumPy module:", np.__name__)
print("Matplotlib pyplot module:", plt.__name__)
Before you run the final check, do you expect both imports to succeed with the interpreter you selected?
- Click the Run Python File play button in the top-right corner of the editor.
You should see NumPy module: numpy plus Matplotlib pyplot module: matplotlib.pyplot in the terminal. No exception should appear.
That's the foundation locked in. Your editor can now run the exact package versions behind this study.
Setup check raised an exception?
- Confirm that the Visual Studio Code Status Bar points to tutorial_env.
- Confirm that the package installation finished inside the activated environment.
- Confirm that both import lines match the full-code reference exactly.
Still stuck? Help me troubleshoot why check_setup.py cannot import NumPy or Matplotlib from tutorial_env.
Your reproducible workspace is ready for research. Next, you will generate a synthetic market with a visible regime change.
Build a Regime-Changing Market
Your reproducible workspace is ready. Now you need a market where the true pattern is known before you test any strategy.
A controlled synthetic time series gives you known ground truth. That lets you test the research method without real-data ambiguity.
NumPy generates 600 deterministic observations across two temporal regimes. Matplotlib plots the split after observation 400.
In this step, get ready to:
- Define fixed settings for a deterministic 600-observation market.
- Generate persistent returns before observation 400.
- Plot the price path with its chronological boundary.
Model two temporal regimes
Return persistence gives the first regime short-term momentum. The later regime flips direction on every observation to create a controlled change in behavior.
- Create a signal_or_noise.py file inside the signal-or-noise folder using the new-file icon beside the folder name.
- Build the market generator by pasting this code into signal_or_noise.py:
import matplotlib.pyplot as plt
import numpy as np
SEED = 21
OBSERVATIONS = 600
TRAIN_END = 400
DAILY_VOLATILITY = 0.01
MOMENTUM_STRENGTH = 0.35
def simulate_regime_shift(seed=SEED):
rng = np.random.default_rng(seed)
shocks = rng.normal(0.0, DAILY_VOLATILITY, OBSERVATIONS)
returns = np.empty(OBSERVATIONS)
returns[0] = shocks[0]
for index in range(1, TRAIN_END):
returns[index] = MOMENTUM_STRENGTH * returns[index - 1] + shocks[index]
for index in range(TRAIN_END, OBSERVATIONS):
direction = 1.0 if (index - TRAIN_END) % 2 == 0 else -1.0
returns[index] = direction * abs(shocks[index])
return returns
How does the market change?
- The fixed SEED produces the same random shocks on every run.
- Before TRAIN_END, each return carries part of the previous return into the next observation.
- From TRAIN_END onward, the direction flips on every observation.
- The absolute shock controls the return magnitude in the alternating regime.
- Save signal_or_noise.py.
- Confirm signal_or_noise.py remains visible in the workspace file list.
Can't find the new file?
- Expand the signal-or-noise folder in the workspace sidebar.
- Check that the filename ends with .py.
- Rename the file if Visual Studio Code added a different extension.
Still stuck? Help me create signal_or_noise.py in my existing Visual Studio Code workspace..
Plot the market path
A cumulative price path makes the regime boundary visible. The plotting workflow also prints the observation count so you can verify the generated array.
- Place the plotting workflow below return returns by pasting this code:
def main():
returns = simulate_regime_shift()
price_path = 100.0 + 100.0 * np.cumsum(returns)
figure, axis = plt.subplots(figsize=(10, 4))
axis.plot(price_path, label="Synthetic price path")
axis.axvline(TRAIN_END, color="black", linestyle="--", label="Test period begins")
axis.set_title("Synthetic market with a regime change")
axis.legend()
figure.savefig("market_path.png", dpi=150)
plt.close(figure)
print(f"Generated {len(returns)} observations.")
if __name__ == "__main__":
main()
What does this plotting code do?
- The price_path calculation converts cumulative returns into a price index starting at 100.
- The vertical line at TRAIN_END marks the chronological split between the two regimes.
- The figure is saved as market_path.png so the result remains available after the script closes the plotting window.
- The final message reports the generated array length.
- Save signal_or_noise.py.
Before you run the script, where do you expect the market's behavior to change?
- Select the signal_or_noise.py editor tab.
- Click the Run Python File play button in the top-right corner of the editor.
The terminal prints Generated 600 observations. A new market_path.png file appears in the workspace file list.
- Select market_path.png from the workspace file list.
You'll see a dashed vertical line after observation 400. The line marks where the persistent training regime gives way to the alternating test regime.
That is your first research artifact working: a deterministic market with a visible chronological split.
Missing the chart or observation count?
- Confirm signal_or_noise.py is the active editor tab before selecting Run Python File.
- Confirm the interpreter selected earlier is still tutorial_env if an import fails.
- Compare OBSERVATIONS = 600 with your file if the terminal reports a different count.
Still stuck? Help me debug the missing market_path.png file or incorrect observation count..
✔️ Awesome, I've got everything!
Your saved script now generates the two regimes and creates the expected market chart.
ⓧ I'd like to double check the full code
import matplotlib.pyplot as plt
import numpy as np
SEED = 21
OBSERVATIONS = 600
TRAIN_END = 400
DAILY_VOLATILITY = 0.01
MOMENTUM_STRENGTH = 0.35
def simulate_regime_shift(seed=SEED):
rng = np.random.default_rng(seed)
shocks = rng.normal(0.0, DAILY_VOLATILITY, OBSERVATIONS)
returns = np.empty(OBSERVATIONS)
returns[0] = shocks[0]
for index in range(1, TRAIN_END):
returns[index] = MOMENTUM_STRENGTH * returns[index - 1] + shocks[index]
for index in range(TRAIN_END, OBSERVATIONS):
direction = 1.0 if (index - TRAIN_END) % 2 == 0 else -1.0
returns[index] = direction * abs(shocks[index])
return returns
def main():
returns = simulate_regime_shift()
price_path = 100.0 + 100.0 * np.cumsum(returns)
figure, axis = plt.subplots(figsize=(10, 4))
axis.plot(price_path, label="Synthetic price path")
axis.axvline(TRAIN_END, color="black", linestyle="--", label="Test period begins")
axis.set_title("Synthetic market with a regime change")
axis.legend()
figure.savefig("market_path.png", dpi=150)
plt.close(figure)
print(f"Generated {len(returns)} observations.")
if __name__ == "__main__":
main()
How is the file organized?
The constants lock down the generated market. simulate_regime_shift() creates the persistent training returns plus the alternating test returns.
The main() function converts the returns into a price path. It saves the chart before printing the observation count.
Your controlled market now has a known regime change that you can test against. Next, you'll put a trading rule against this market and inspect the result.
Expose the Leaky Backtest
Your synthetic market now runs in Visual Studio Code. It gives you a controlled place to test a trading idea.
A polished backtest can feel convincing before anyone checks its information timing. This step lets a perfect-looking result appear before you investigate what made it possible.
In this step, get ready to:
- Build a strategy evaluator that converts each return into a position.
- Run the evaluator to expose a suspicious positive-period rate.
- Diagnose the information timing that creates the result.
Build the tempting strategy
A position represents the direction a strategy takes during each observation. The first evaluator chooses that direction from the return being evaluated.
- Switch back to signal_or_noise.py in the editor.
- Place your cursor on the blank line between return returns and def main():.
- Add the evaluator by copying this function:
def evaluate_strategy(returns):
leaky_position = np.sign(returns)
leaky_returns = leaky_position * returns
return leaky_returns
What does this code do?
- The evaluate_strategy() function receives the synthetic return series.
- The np.sign(returns) expression converts positive returns to long positions.
- The same expression converts negative returns to short positions.
- The leaky_position * returns calculation applies each position to its corresponding return.
- Save signal_or_noise.py.
- Click Run Python File in the top-right corner of the editor.
You should still see Generated 600 observations. in the terminal. Your existing market_path.png chart is also refreshed.
Good, your market generator still works with the evaluator defined beside it.
Does the setup check fail now?
- Check that evaluate_strategy() starts at the left edge of the file.
- Check that its three body lines use the same four-space indentation.
- Keep the function above main() so Python defines it first.
Still stuck? Help me fix the evaluate_strategy function in signal_or_noise.py.
Run the perfect-looking backtest
The evaluator now needs to receive the simulated returns. The main function can print the fraction of strategy returns that are positive.
- Find the existing main() function in signal_or_noise.py.
- Use this block to identify the function you are replacing:
def main():
returns = simulate_regime_shift()
price_path = 100.0 + 100.0 * np.cumsum(returns)
figure, axis = plt.subplots(figsize=(10, 4))
axis.plot(price_path, label="Synthetic price path")
axis.axvline(TRAIN_END, color="black", linestyle="--", label="Test period begins")
axis.set_title("Synthetic market with a regime change")
axis.legend()
figure.savefig("market_path.png", dpi=150)
plt.close(figure)
print(f"Generated {len(returns)} observations.")
What does the current function do?
The current main() function simulates the market. It then saves the price-path chart from the previous step.
- Replace the existing main() function with this version:
def main():
returns = simulate_regime_shift()
leaky_returns = evaluate_strategy(returns)
print(f"Leaky positive periods: {float(np.mean(leaky_returns > 0)):.1%}")
What does the updated function do?
- The first line creates the same deterministic return series.
- The second line passes those returns into evaluate_strategy().
- The final line calculates the share of leaky_returns above zero.
- Save signal_or_noise.py.
Before you run the backtest, do you expect an untested one-period signal to produce a flawless result?
- Click Run Python File in the top-right corner of the editor.
You will see Leaky positive periods: 100.0% in the terminal. This perfect result is the planned failure.
There it is: the strategy appears to choose the profitable direction in every nonzero period. Your existing market_path.png remains in the signal-or-noise folder.
Why is the result suspicious?
The result contains look-ahead bias. Each value in leaky_position comes from the current return.
That return becomes observable only after the period has occurred. The strategy therefore uses future information at the moment it claims to choose a position.
Not seeing the perfect result?
- Check that main() calls evaluate_strategy(returns).
- Check that the positive-period calculation compares leaky_returns with zero.
- Check that the final two lines still call main() when the file runs.
Still stuck? Help me diagnose why my leaky backtest does not print a 100 percent positive-period rate.
Document the information leak
A research script should explain why a diagnostic result cannot be implemented. A focused comment keeps that warning beside the exact calculation that causes the problem.
- Find these two lines inside evaluate_strategy().
leaky_position = np.sign(returns)
leaky_returns = leaky_position * returns
What should you inspect?
Both lines use the same returns array without any timing shift. That shared index is the source of the suspicious result.
- Insert the explanatory comment between those two lines so the code looks like this:
leaky_position = np.sign(returns)
# This position uses the current return before it could have been observed.
leaky_returns = leaky_position * returns
Why keep the comment here?
The comment records the timing flaw beside leaky_returns. Future readers can see why the perfect statistic is diagnostic evidence instead of valid performance.
- Save signal_or_noise.py.
Compare your completed file before the final run.
✔️ Awesome, I've got everything!
Your evaluator is documented. Make sure signal_or_noise.py is saved.
ⓧ I'd like to double check the full code
import matplotlib.pyplot as plt
import numpy as np
SEED = 21
OBSERVATIONS = 600
TRAIN_END = 400
DAILY_VOLATILITY = 0.01
MOMENTUM_STRENGTH = 0.35
def simulate_regime_shift(seed=SEED):
rng = np.random.default_rng(seed)
shocks = rng.normal(0.0, DAILY_VOLATILITY, OBSERVATIONS)
returns = np.empty(OBSERVATIONS)
returns[0] = shocks[0]
for index in range(1, TRAIN_END):
returns[index] = MOMENTUM_STRENGTH * returns[index - 1] + shocks[index]
for index in range(TRAIN_END, OBSERVATIONS):
direction = 1.0 if (index - TRAIN_END) % 2 == 0 else -1.0
returns[index] = direction * abs(shocks[index])
return returns
def evaluate_strategy(returns):
leaky_position = np.sign(returns)
# This position uses the current return before it could have been observed.
leaky_returns = leaky_position * returns
return leaky_returns
def main():
returns = simulate_regime_shift()
leaky_returns = evaluate_strategy(returns)
print(f"Leaky positive periods: {float(np.mean(leaky_returns > 0)):.1%}")
if __name__ == "__main__":
main()
Before the final run, should a code comment change the numerical result?
- Click Run Python File in the top-right corner of the editor.
The terminal still ends with Leaky positive periods: 100.0%. The unchanged output confirms that your comment documents the leak without changing the calculation.
You have exposed a perfect result that relies on unavailable information. Next, you will test whether the momentum hypothesis survives honest timing.
Correct the Signal Timing
The suspicious 100 percent result did its job. It exposed how look-ahead bias can turn future information into apparent skill.
This step rebuilds the position with information available before each return occurs. You will evaluate that corrected strategy with a chronological holdout that keeps observations 400 through 599 untouched until testing.
In this step, get ready to:
- Shift each position back by one observation.
- Deduct illustrative turnover costs from the gross strategy returns.
- Compare training results with the untouched test results.
Build the implementable position
A position for observation t can use the return from observation t - 1. This timing makes the decision available before the next return occurs.
- In signal_or_noise.py, find the comment above leaky_returns = leaky_position * returns.
- Replace that comment plus the leaky_returns line with this expanded calculation:
leaky_returns = leaky_position * returns
position = np.empty(len(returns))
position[0] = 0.0
position[1:] = np.sign(returns[:-1])
gross_returns = position * returns
How does the timing correction work?
- The first position is 0.0 because no previous return exists.
- The slice returns[:-1] contains every return except the final one.
- The assignment to position[1:] moves those signs forward by one observation.
- The multiplication in gross_returns measures the corrected strategy before costs.
- Save signal_or_noise.py.
Before you run the file, do you expect the original leaky diagnostic to remain visible?
- Click the Run Python File button in the top-right corner of Visual Studio Code.
The terminal still reports a 100 percent positive-period rate for the leaky strategy. That suspicious baseline stays visible so you can compare it with the corrected results later.
Seeing a shape or assignment error?
- Check that position[1:] is paired with returns[:-1].
- Check that position[0] is set before the slice assignment.
Still stuck? Help me check the array slices in my corrected position calculation.
Deduct turnover costs
Changing a position creates turnover. The project deducts a fixed cost from each unit of turnover so frequent switching has a visible consequence.
Why is the cost illustrative?
The rate 0.0005 is a scenario assumption for this study. It does not represent a current or typical cost for any specific market.
- In signal_or_noise.py, find MOMENTUM_STRENGTH = 0.35.
- Insert the cost constant directly below that line by using this two-line section:
MOMENTUM_STRENGTH = 0.35
ILLUSTRATIVE_COST_RATE = 0.0005
What does this constant control?
The ILLUSTRATIVE_COST_RATE constant keeps the cost assumption in one named location. The strategy function can now use the same default on every run.
- Save signal_or_noise.py.
- Click the Run Python File button.
The leaky diagnostic still prints successfully. This confirms that the new assumption has not disrupted the existing calculation.
- Find def evaluate_strategy(returns): in signal_or_noise.py.
- Replace that function signature with this version:
def evaluate_strategy(returns, cost_rate=ILLUSTRATIVE_COST_RATE):
Why pass the cost into the function?
The cost_rate parameter lets the function evaluate another assumption when one is provided. The named constant supplies the default for this study.
- Save signal_or_noise.py.
- Click the Run Python File button.
The terminal prints the leaky diagnostic again. The function now accepts a cost assumption while preserving its existing output.
- Find gross_returns = position * returns inside evaluate_strategy().
- Insert the turnover calculation directly below that line by copying this block:
turnover = np.empty(len(returns))
turnover[0] = abs(position[0])
turnover[1:] = abs(position[1:] - position[:-1])
net_returns = gross_returns - turnover * cost_rate
How are net returns calculated?
- The turnover array measures the absolute position change at each observation.
- A switch from 1.0 to -1.0 creates two units of turnover.
- The net_returns calculation subtracts the assumed cost from each gross return.
- Save signal_or_noise.py.
- Click the Run Python File button.
The script completes with the leaky diagnostic intact. The gross return plus the cost-adjusted return are now ready for chronological comparison.
Seeing an undefined cost name?
- Check that ILLUSTRATIVE_COST_RATE = 0.0005 appears with the other constants.
- Check that the evaluate_strategy() signature includes cost_rate=ILLUSTRATIVE_COST_RATE.
Still stuck? Help me trace the cost_rate value through evaluate_strategy.
Compare the chronological periods
The training period contains observations 0 through 399. The untouched test period contains observations 400 through 599.
The split stays chronological because later observations represent the evidence available after the research idea was formed. The test result reveals whether the momentum pattern survives the regime change.
- Find return leaky_returns near the end of evaluate_strategy().
- Replace everything from that return line through the end of signal_or_noise.py with this completed section:
return leaky_returns, gross_returns, net_returns
def main():
returns = simulate_regime_shift()
leaky_returns, gross_returns, net_returns = evaluate_strategy(returns)
print(f"Leaky positive periods: {float(np.mean(leaky_returns > 0)):.1%}")
for label, values in (("Training gross", gross_returns[1:TRAIN_END]), ("Training net", net_returns[1:TRAIN_END]), ("Test gross", gross_returns[TRAIN_END:]), ("Test net", net_returns[TRAIN_END:])):
print(f"{label} mean return: {float(np.mean(values)):.6%}")
print(f"{label} positive periods: {float(np.mean(values > 0)):.1%}")
if __name__ == "__main__":
main()
What does the comparison show?
- The function returns the leaky diagnostic plus the corrected gross and net series.
- The training slices begin at observation 1 because observation 0 has no previous signal.
- The test slices begin at TRAIN_END so the final 200 observations remain a later holdout.
- The loop prints the mean return plus the positive-period rate for each corrected series.
- Save signal_or_noise.py.
Before you run the corrected study, do you expect the test-period mean to resemble the perfect leaky result?
- Click the Run Python File button.
The terminal first reports the 100 percent leaky positive-period rate. It then prints separate training gross, training net, test gross, and test net statistics.
The corrected test-period mean is negative. The later alternating regime contradicts the apparently perfect result once the signal uses information available on time.
Missing the training or test statistics?
- Check that evaluate_strategy() returns three arrays in the same order used by main().
- Check that the four label-and-value pairs remain inside the parentheses after for label, values in.
- Check that both print lines are indented inside the loop.
Still stuck? Help me debug the chronological result loop in signal_or_noise.py.
✔️ Awesome, I've got everything!
Your signal now uses the previous return. Your terminal shows separate gross and cost-adjusted statistics for the chronological training and test periods.
ⓧ I'd like to double check the full code
import matplotlib.pyplot as plt
import numpy as np
SEED = 21
OBSERVATIONS = 600
TRAIN_END = 400
DAILY_VOLATILITY = 0.01
MOMENTUM_STRENGTH = 0.35
ILLUSTRATIVE_COST_RATE = 0.0005
def simulate_regime_shift(seed=SEED):
rng = np.random.default_rng(seed)
shocks = rng.normal(0.0, DAILY_VOLATILITY, OBSERVATIONS)
returns = np.empty(OBSERVATIONS)
returns[0] = shocks[0]
for index in range(1, TRAIN_END):
returns[index] = MOMENTUM_STRENGTH * returns[index - 1] + shocks[index]
for index in range(TRAIN_END, OBSERVATIONS):
direction = 1.0 if (index - TRAIN_END) % 2 == 0 else -1.0
returns[index] = direction * abs(shocks[index])
return returns
def evaluate_strategy(returns, cost_rate=ILLUSTRATIVE_COST_RATE):
leaky_position = np.sign(returns)
leaky_returns = leaky_position * returns
position = np.empty(len(returns))
position[0] = 0.0
position[1:] = np.sign(returns[:-1])
gross_returns = position * returns
turnover = np.empty(len(returns))
turnover[0] = abs(position[0])
turnover[1:] = abs(position[1:] - position[:-1])
net_returns = gross_returns - turnover * cost_rate
return leaky_returns, gross_returns, net_returns
def main():
returns = simulate_regime_shift()
leaky_returns, gross_returns, net_returns = evaluate_strategy(returns)
print(f"Leaky positive periods: {float(np.mean(leaky_returns > 0)):.1%}")
for label, values in (("Training gross", gross_returns[1:TRAIN_END]), ("Training net", net_returns[1:TRAIN_END]), ("Test gross", gross_returns[TRAIN_END:]), ("Test net", net_returns[TRAIN_END:])):
print(f"{label} mean return: {float(np.mean(values)):.6%}")
print(f"{label} positive periods: {float(np.mean(values > 0)):.1%}")
if __name__ == "__main__":
main()
That is the core timing fix done. Next, you will measure how uncertain the holdout result is before publishing the finding.
Quantify Uncertainty and Publish Findings
Your corrected strategy now uses prior information and preserves chronological order. The holdout result shows that the apparent momentum edge fails in the later regime.
A single mean can still hide sampling uncertainty. You will use a moving-block bootstrap to estimate an interval before publishing the evidence in a chart and a written report.
In this step, get ready to:
- Resample the test-period net returns in consecutive five-observation blocks.
- Turn the chronological statistics into a concise research conclusion.
- Publish a three-panel figure and a text report.
Resample the holdout period
The test returns form an ordered time series. A moving-block bootstrap resamples consecutive observations together. This preserves short stretches of dependence inside each sampled block.
Why use consecutive blocks?
Sampling individual observations would discard their local ordering. Five-observation blocks keep nearby returns together while constructing alternative holdout samples.
The block length is a teaching choice for this study. It gives you a dependence-aware interval without claiming that five observations is universally optimal.
- Switch back to signal_or_noise.py in Visual Studio Code.
- Find SEED = 21 near the top of the file.
- Add the bootstrap seed directly below it by copying this line:
BOOTSTRAP_SEED = 22
What does this constant control?
The bootstrap uses a separate fixed seed. Reusing this value produces the same resampled distribution on every run.
- Find ILLUSTRATIVE_COST_RATE = 0.0005 in the same constants section.
- Add the bootstrap sample count and block size directly below it by copying these lines:
BOOTSTRAP_SAMPLES = 1000
BLOCK_SIZE = 5
What do these constants control?
- BOOTSTRAP_SAMPLES controls how many alternative holdout samples contribute to the distribution.
- BLOCK_SIZE keeps each selected run of observations five periods long.
- Save signal_or_noise.py.
- Click Run Python File in the top-right corner.
You will see the existing leaky diagnostic plus the training and test summaries. The script reaches the final test statistic without an exception.
- Find the end of evaluate_strategy().
- Add the bootstrap function below its return statement by copying this code:
def moving_block_bootstrap_means(values, seed=BOOTSTRAP_SEED):
rng = np.random.default_rng(seed)
number_of_blocks = (len(values) + BLOCK_SIZE - 1) // BLOCK_SIZE
possible_starts = len(values) - BLOCK_SIZE + 1
bootstrap_means = np.empty(BOOTSTRAP_SAMPLES)
sample = np.empty(number_of_blocks * BLOCK_SIZE)
for sample_index in range(BOOTSTRAP_SAMPLES):
starts = rng.choice(
possible_starts,
size=number_of_blocks,
replace=True,
)
cursor = 0
for start in starts:
sample[cursor : cursor + BLOCK_SIZE] = values[start : start + BLOCK_SIZE]
cursor += BLOCK_SIZE
bootstrap_means[sample_index] = np.mean(sample[: len(values)])
interval = np.percentile(bootstrap_means, [2.5, 97.5])
return bootstrap_means, float(interval[0]), float(interval[1])
What does this code do?
- The NumPy generator selects valid block starting points with replacement.
- Each selected start contributes five consecutive test returns to the resampled series.
- The loop stores the mean from each completed sample in bootstrap_means.
- The percentile calculation extracts the lower and upper boundaries of a 95 percent interval.
- Save signal_or_noise.py.
- Click Run Python File again.
You will see the familiar strategy statistics through the test-period results. Reaching the end confirms that Python accepted the bootstrap function.
Seeing an indentation or syntax error?
- Check that moving_block_bootstrap_means() starts at the left edge of the file.
- Check that the lines inside both loops use consistent four-space indentation.
- Check that the percentile line appears after both loops have finished.
Still stuck? Help me check the structure of my moving-block bootstrap function
Turn statistics into a written conclusion
A research report needs consistent statistics for every period. It also needs a decision rule that translates the interval into a supported, contradicted, or unresolved conclusion.
- Find moving_block_bootstrap_means() in signal_or_noise.py.
- Add the statistics helper below that function by copying this code:
def describe(label, values):
return [
f"{label} mean return: {float(np.mean(values)):.6%}",
f"{label} volatility: {float(np.std(values, ddof=1)):.6%}",
f"{label} positive periods: {float(np.mean(values > 0)):.1%}",
]
What does this helper calculate?
- The mean summarizes the average return for the selected period.
- The sample volatility describes the spread of those returns.
- The positive-period rate reports how often the strategy return is above zero.
- Save signal_or_noise.py.
- Click Run Python File.
You will see the existing strategy diagnostics finish normally. This confirms that the new statistics helper is valid Python.
Seeing an error near the returned list?
- Check that each formatted string ends with a comma.
- Check that the closing square bracket aligns with return.
Need another pair of eyes? Help me check my describe function
- Place the conclusion helper directly below describe() by copying this code:
def build_conclusion(interval_low, interval_high):
if interval_low > 0:
return "The momentum hypothesis is supported in the synthetic holdout regime."
if interval_high < 0:
return "The momentum hypothesis is contradicted in the synthetic holdout regime."
return "The synthetic holdout evidence is inconclusive because the interval contains zero."
How is the conclusion chosen?
- An interval entirely above zero supports the momentum hypothesis in the synthetic holdout regime.
- An interval entirely below zero contradicts the momentum hypothesis in the synthetic holdout regime.
- An interval containing zero leaves the evidence inconclusive.
- Save signal_or_noise.py.
- Click Run Python File.
You will see the test-period statistics print without a function-definition error. The decision rule is now ready for the report.
Seeing a problem in the decision rule?
- Check that the positive interval test uses interval_low > 0.
- Check that the negative interval test uses interval_high < 0.
- Check that the inconclusive return sits outside both conditional blocks.
Still seeing an error? Help me debug my interval-based conclusion function
- Add the report writer directly below build_conclusion() by copying this code:
def write_report(leaky_returns, gross_returns, net_returns, interval_low, interval_high):
conclusion = build_conclusion(interval_low, interval_high)
lines = [
"Signal or Noise: Short-Term Momentum Study",
"",
"Hypothesis: the sign of the previous return predicts the sign of the next return.",
f"Chronological split: observations 0-{TRAIN_END - 1} train, {TRAIN_END}-{OBSERVATIONS - 1} test.",
f"Illustrative turnover cost rate: {ILLUSTRATIVE_COST_RATE:.4%} per unit of turnover.",
f"Leaky full-sample positive periods: {float(np.mean(leaky_returns > 0)):.1%}.",
"The leaky position uses the current return and is not implementable.",
"",
*describe("Training gross", gross_returns[1:TRAIN_END]),
*describe("Training net", net_returns[1:TRAIN_END]),
"",
*describe("Test gross", gross_returns[TRAIN_END:]),
*describe("Test net", net_returns[TRAIN_END:]),
f"Test net mean 95% moving-block bootstrap interval: [{interval_low:.6%}, {interval_high:.6%}]",
"",
f"Conclusion: {conclusion}",
"Limitation: the data is synthetic and the cost rate is an illustrative assumption, not an estimate for a specific market.",
]
report_text = "\n".join(lines)
with open("research_summary.txt", "w", encoding="utf-8") as report_file:
report_file.write(report_text)
return report_text
What goes into the report?
- The opening records the hypothesis and the chronological split.
- The diagnostic preserves the suspicious leaky result as evidence of look-ahead bias.
- The body compares gross and net statistics across the training and test periods.
- The ending records the bootstrap interval, the conclusion, and the study limitation.
- Save signal_or_noise.py.
- Click Run Python File.
You will see the previous terminal summary complete without an exception. Python has now loaded every function needed to write the final report.
Seeing an error in write_report()?
- Check that every entry in lines ends with a comma.
- Check that both starred describe() calls remain inside the list.
- Check that the file-writing lines stay indented inside write_report().
Need help tracing it? Help me debug my research report writer
Create the final figure and publish the study
The final artifact needs to show the market, the corrected strategy, and the interval evidence in one view. Matplotlib will place those three views into a single saved figure.
- Add the figure function directly below write_report() by copying this code:
def create_figure(returns, gross_returns, net_returns, bootstrap_means, interval_low, interval_high):
price_path = 100.0 + 100.0 * np.cumsum(returns)
figure, axes = plt.subplots(3, 1, figsize=(10, 10), layout="constrained")
axes[0].plot(price_path, label="Synthetic price path")
axes[0].axvline(TRAIN_END, color="black", linestyle="--", label="Test period begins")
axes[0].set_title("Synthetic market with a regime change")
axes[0].set_ylabel("Price index")
axes[0].legend()
axes[1].plot(np.cumsum(gross_returns), label="Corrected gross cumulative return")
axes[1].plot(np.cumsum(net_returns), label="Corrected net cumulative return")
axes[1].axvline(TRAIN_END, color="black", linestyle="--", label="Test period begins")
axes[1].set_title("Time-correct strategy performance")
axes[1].set_ylabel("Cumulative return")
axes[1].legend()
axes[2].hist(bootstrap_means * 100.0, bins=30, color="steelblue", alpha=0.8)
axes[2].axvline(0.0, color="black", linestyle="--", label="Zero")
axes[2].axvline(interval_low * 100.0, color="darkred", linestyle=":", label="95% interval")
axes[2].axvline(interval_high * 100.0, color="darkred", linestyle=":")
axes[2].set_title("Moving-block bootstrap of test mean")
axes[2].set_ylabel("Frequency")
axes[2].legend()
figure.savefig("signal_or_noise.png", dpi=150)
plt.close(figure)
How is the figure organized?
- The first panel shows the synthetic price path with the test boundary marked.
- The second panel compares corrected cumulative gross and net returns.
- The third panel shows the bootstrap distribution with zero and both interval boundaries marked.
- Closing the figure releases it after signal_or_noise.png is saved.
- Save signal_or_noise.py.
- Click Run Python File.
You will see the existing diagnostics reach the end without a plotting error. The figure function is ready for the final workflow.
Seeing a plotting syntax error?
- Check that every plotting line begins with the correct axes index.
- Check that both interval boundaries use the third panel.
- Check that figure.savefig() remains inside create_figure().
Still stuck? Help me debug my three-panel Matplotlib figure
- Find the existing main() function near the bottom of signal_or_noise.py.
- Select the complete function from def main(): through its current print loop.
- Replace the selected function with this completed workflow:
def main():
returns = simulate_regime_shift()
leaky_returns, gross_returns, net_returns = evaluate_strategy(returns)
test_net_returns = net_returns[TRAIN_END:]
bootstrap_means, interval_low, interval_high = moving_block_bootstrap_means(test_net_returns)
report_text = write_report(
leaky_returns,
gross_returns,
net_returns,
interval_low,
interval_high,
)
create_figure(
returns,
gross_returns,
net_returns,
bootstrap_means,
interval_low,
interval_high,
)
print(report_text)
print("\nStudy complete.")
print("Created signal_or_noise.png and research_summary.txt")
What does the completed workflow do?
- The workflow isolates the untouched test-period net returns before resampling them.
- The report writer receives the strategy results and both interval boundaries.
- The figure receives the returns, cumulative strategy series, and bootstrap evidence.
- The terminal prints the same report that is saved to research_summary.txt.
- Save signal_or_noise.py.
Before you run the completed study, predict whether the bootstrap interval will sit above zero, below zero, or cross zero:
- Click Run Python File in the top-right corner.
The terminal prints the complete report. It ends with Study complete. followed by Created signal_or_noise.png and research_summary.txt.
- Select signal_or_noise.png from the file list on the left.
You will see three labeled panels. They show the synthetic market, the corrected cumulative returns, and the bootstrap distribution.
- Select research_summary.txt from the file list on the left.
You will see the hypothesis, the leaky diagnostic, the chronological statistics, the illustrative cost assumption, the bootstrap interval, the conclusion, and the limitation.
Missing the figure or report?
- Check that write_report() and create_figure() are both called inside main().
- Check that the final if __name__ == "__main__": block still calls main().
- Check the terminal for the first function named in the traceback.
Need help locating the failure? Help me debug why my final study did not create both output files
That is the full research loop complete. You have turned a suspicious backtest into a reproducible finding that communicates timing, costs, holdout performance, and uncertainty.
✔️ Awesome, I've got everything!
Great work. Save signal_or_noise.py one final time so the completed study remains reproducible.
ⓧ I'd like to double check the full code
import matplotlib.pyplot as plt
import numpy as np
SEED = 21
BOOTSTRAP_SEED = 22
OBSERVATIONS = 600
TRAIN_END = 400
DAILY_VOLATILITY = 0.01
MOMENTUM_STRENGTH = 0.35
ILLUSTRATIVE_COST_RATE = 0.0005
BOOTSTRAP_SAMPLES = 1000
BLOCK_SIZE = 5
def simulate_regime_shift(seed=SEED):
rng = np.random.default_rng(seed)
shocks = rng.normal(0.0, DAILY_VOLATILITY, OBSERVATIONS)
returns = np.empty(OBSERVATIONS)
returns[0] = shocks[0]
for index in range(1, TRAIN_END):
returns[index] = MOMENTUM_STRENGTH * returns[index - 1] + shocks[index]
for index in range(TRAIN_END, OBSERVATIONS):
direction = 1.0 if (index - TRAIN_END) % 2 == 0 else -1.0
returns[index] = direction * abs(shocks[index])
return returns
def evaluate_strategy(returns, cost_rate=ILLUSTRATIVE_COST_RATE):
leaky_position = np.sign(returns)
leaky_returns = leaky_position * returns
position = np.empty(len(returns))
position[0] = 0.0
position[1:] = np.sign(returns[:-1])
gross_returns = position * returns
turnover = np.empty(len(returns))
turnover[0] = abs(position[0])
turnover[1:] = abs(position[1:] - position[:-1])
net_returns = gross_returns - turnover * cost_rate
return leaky_returns, gross_returns, net_returns
def moving_block_bootstrap_means(values, seed=BOOTSTRAP_SEED):
rng = np.random.default_rng(seed)
number_of_blocks = (len(values) + BLOCK_SIZE - 1) // BLOCK_SIZE
possible_starts = len(values) - BLOCK_SIZE + 1
bootstrap_means = np.empty(BOOTSTRAP_SAMPLES)
sample = np.empty(number_of_blocks * BLOCK_SIZE)
for sample_index in range(BOOTSTRAP_SAMPLES):
starts = rng.choice(
possible_starts,
size=number_of_blocks,
replace=True,
)
cursor = 0
for start in starts:
sample[cursor : cursor + BLOCK_SIZE] = values[start : start + BLOCK_SIZE]
cursor += BLOCK_SIZE
bootstrap_means[sample_index] = np.mean(sample[: len(values)])
interval = np.percentile(bootstrap_means, [2.5, 97.5])
return bootstrap_means, float(interval[0]), float(interval[1])
def describe(label, values):
return [
f"{label} mean return: {float(np.mean(values)):.6%}",
f"{label} volatility: {float(np.std(values, ddof=1)):.6%}",
f"{label} positive periods: {float(np.mean(values > 0)):.1%}",
]
def build_conclusion(interval_low, interval_high):
if interval_low > 0:
return "The momentum hypothesis is supported in the synthetic holdout regime."
if interval_high < 0:
return "The momentum hypothesis is contradicted in the synthetic holdout regime."
return "The synthetic holdout evidence is inconclusive because the interval contains zero."
def write_report(leaky_returns, gross_returns, net_returns, interval_low, interval_high):
conclusion = build_conclusion(interval_low, interval_high)
lines = [
"Signal or Noise: Short-Term Momentum Study",
"",
"Hypothesis: the sign of the previous return predicts the sign of the next return.",
f"Chronological split: observations 0-{TRAIN_END - 1} train, {TRAIN_END}-{OBSERVATIONS - 1} test.",
f"Illustrative turnover cost rate: {ILLUSTRATIVE_COST_RATE:.4%} per unit of turnover.",
f"Leaky full-sample positive periods: {float(np.mean(leaky_returns > 0)):.1%}.",
"The leaky position uses the current return and is not implementable.",
"",
*describe("Training gross", gross_returns[1:TRAIN_END]),
*describe("Training net", net_returns[1:TRAIN_END]),
"",
*describe("Test gross", gross_returns[TRAIN_END:]),
*describe("Test net", net_returns[TRAIN_END:]),
f"Test net mean 95% moving-block bootstrap interval: [{interval_low:.6%}, {interval_high:.6%}]",
"",
f"Conclusion: {conclusion}",
"Limitation: the data is synthetic and the cost rate is an illustrative assumption, not an estimate for a specific market.",
]
report_text = "\n".join(lines)
with open("research_summary.txt", "w", encoding="utf-8") as report_file:
report_file.write(report_text)
return report_text
def create_figure(returns, gross_returns, net_returns, bootstrap_means, interval_low, interval_high):
price_path = 100.0 + 100.0 * np.cumsum(returns)
figure, axes = plt.subplots(3, 1, figsize=(10, 10), layout="constrained")
axes[0].plot(price_path, label="Synthetic price path")
axes[0].axvline(TRAIN_END, color="black", linestyle="--", label="Test period begins")
axes[0].set_title("Synthetic market with a regime change")
axes[0].set_ylabel("Price index")
axes[0].legend()
axes[1].plot(np.cumsum(gross_returns), label="Corrected gross cumulative return")
axes[1].plot(np.cumsum(net_returns), label="Corrected net cumulative return")
axes[1].axvline(TRAIN_END, color="black", linestyle="--", label="Test period begins")
axes[1].set_title("Time-correct strategy performance")
axes[1].set_ylabel("Cumulative return")
axes[1].legend()
axes[2].hist(bootstrap_means * 100.0, bins=30, color="steelblue", alpha=0.8)
axes[2].axvline(0.0, color="black", linestyle="--", label="Zero")
axes[2].axvline(interval_low * 100.0, color="darkred", linestyle=":", label="95% interval")
axes[2].axvline(interval_high * 100.0, color="darkred", linestyle=":")
axes[2].set_title("Moving-block bootstrap of test mean")
axes[2].set_ylabel("Frequency")
axes[2].legend()
figure.savefig("signal_or_noise.png", dpi=150)
plt.close(figure)
def main():
returns = simulate_regime_shift()
leaky_returns, gross_returns, net_returns = evaluate_strategy(returns)
test_net_returns = net_returns[TRAIN_END:]
bootstrap_means, interval_low, interval_high = moving_block_bootstrap_means(test_net_returns)
report_text = write_report(
leaky_returns,
gross_returns,
net_returns,
interval_low,
interval_high,
)
create_figure(
returns,
gross_returns,
net_returns,
bootstrap_means,
interval_low,
interval_high,
)
print(report_text)
print("\nStudy complete.")
print("Created signal_or_noise.png and research_summary.txt")
if __name__ == "__main__":
main()
Secret mission
Stress-Test the Cost Assumption
Your study reaches its holdout conclusion using one illustrative turnover cost. Sweep five cost rates to discover whether that conclusion is robust or dependent on a single assumption.
Clean Up Your Resources
Clean Up Your Resources
Everything in this project runs locally, so there are no ongoing costs. Decide whether to keep your resources, pause your work for later, or delete the local study entirely.
Resources you used:
- Local project folder signal-or-noise, containing requirements.txt, check_setup.py, signal_or_noise.py, and cost_sensitivity.py.
- Local virtual environment tutorial_env, containing the pinned NumPy and Matplotlib packages.
- Generated outputs signal_or_noise.png and research_summary.txt.
Keep everything running
No action needed. Choose this if you want to keep the complete research study available for future experiments.
- Keep the signal-or-noise folder on your computer.
- Leave tutorial_env inside the project folder so the pinned packages remain ready.
- Retain signal_or_noise.png and research_summary.txt as evidence of your findings.
Pause - I'll come back to this later
The local files consume disk space but incur no cost while idle. Pausing ends the active editor session while preserving the complete study.
- End the active terminal session in Visual Studio Code with the terminal's close control.
- Close Visual Studio Code.
- Keep the signal-or-noise folder in its current location for your next session.
Delete - I don't want to use this again
Deleting the folder permanently removes your scripts, virtual environment, chart, and report. The deletion is limited to this workspace, so your other Python projects stay untouched.
- Copy signal_or_noise.png and research_summary.txt to another folder if you want to preserve the final evidence.
- In Visual Studio Code's file list, right-click the top-level signal-or-noise folder.
- Select the option that reveals signal-or-noise in Windows File Explorer.
- Close Visual Studio Code.
- Delete signal-or-noise from Windows File Explorer.
- Confirm signal-or-noise no longer appears in its previous location.
Nice Work!
Nice Work!
You made it! Your reproducible momentum study now exposes a perfect-looking backtest as misleading.
You've learned how to:
- Generate a reproducible synthetic time series with a known regime change. This reveals how a pattern can weaken when market behavior shifts.
- Expose look-ahead bias through a perfect backtest. Correct the signal timing with prior information. Evaluate the strategy on a chronological holdout with illustrative turnover costs.
- Measure uncertainty with a moving-block bootstrap. Present the evidence through the three-panel signal_or_noise.png figure. Communicate the holdout conclusion through research_summary.txt.
- Secret Mission: Run a cost sensitivity analysis across five illustrative assumptions. Document whether the holdout conclusion remains robust across the full sweep.
Ready to quiz yourself?