Build a Risk-Managed Momentum Portfolio

Build a lag-safe momentum backtest with volatility targeting and risk metrics.

Introduction

30 Second Summary

A portfolio can look strong for years before losing a painful amount during one rough stretch. Return alone does not show how difficult that journey can be.

In this project, you will build a command-line research tool that transforms official daily returns from ten momentum deciles into three strategy variants. The tool compares a baseline with portfolios controlled by lagged risk estimates.

What You'll Build

Run one script to compare three portfolio strategies in the terminal before opening a three-panel chart that reveals how risk management changed the outcome.

By the end of this project, you'll have:

  • A runnable portfolio using equal weighting that reveals its maximum drawdown.
  • A side-by-side comparison showing how lagged inverse-volatility weighting changes portfolio risk without look-ahead bias. Its managed variant follows a 12% volatility target under a 1.5 exposure cap.
  • A presentation-ready three-panel risk chart that moves from cumulative wealth to drawdowns before showing managed exposure. Four audit CSV files let you trace each headline result to its source history.
  • Secret Mission: add a faster volatility brake that reacts to short shocks without using future data.

Are there any prerequisites?

You should have completed a momentum-hypothesis project that used pandas tables of daily returns. You also need confidence running Python scripts in Visual Studio Code.

Before We Start

Before the hands-on work begins, choose the portfolio-risk question that your finished report should help you evaluate. This gives every strategy comparison a clear purpose.

Prepare the Quant Workspace

A backtest must produce a result you can reproduce. A drifting interpreter or package version can change behavior before you compare portfolio risk.

This step gives your project a reproducible home in Visual Studio Code. A pinned Python environment keeps every later run inside the same .venv.

In this step, get ready to:
  • Create the local project workspace in Visual Studio Code.
  • Verify a Python 3.11-or-newer interpreter.
  • Install pinned pandas plus Matplotlib packages inside an isolated virtual environment.
Create the project workspace

A named workspace keeps your environment file beside the research files that use it. You will create the risk-managed-momentum folder before checking the available interpreter.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to start Visual Studio Code.

You will see Visual Studio Code open without a project folder selected.

  • Click File in the top menu.
  • Select Open Folder.
  • Click New folder in the folder picker.
  • Enter risk-managed-momentum as the folder name.
  • Click Select Folder.

The Explorer sidebar now shows risk-managed-momentum as the open folder. This gives every file in the project one predictable location.

  • Click Terminal in the top menu.
  • Select New Terminal.
  • Check the available Python version by running this command:
python --version

What does this command check?

The command prints the version used when the terminal resolves Python. This project needs version 3.11 or newer because both pinned research packages require it.

✔️ I see version 3.11 or higher

Your interpreter clears the first reproducibility check. Keep this workspace open for the environment setup.

ⓧ I see an older version

The package pins require Python 3.11 or newer. Install a current 64-bit Windows release before creating the project environment.

  • Open the Python Windows releases page.
  • Select the current 64-bit Windows installer.
  • Run the downloaded installer.
  • Complete the Python installation.
  • Close Visual Studio Code after the installer finishes.

Restarting Visual Studio Code lets its terminal read the updated Python installation.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to restart Visual Studio Code.
  • Return to the risk-managed-momentum workspace from the recent folders list.
  • Create a fresh terminal from the Terminal menu.
  • Check the updated Python version by running this command:
python --version

What should I see?

The command now reports Python 3.11 or newer. That output confirms the newer interpreter is available to this workspace.

Still seeing the older version?

Close every Visual Studio Code window before starting it again. This forces the application to read the updated Windows environment.

If the old version remains, help me find the newer Python interpreter on Windows.

ⓧ Command not found

Windows cannot currently resolve a Python installation. Install a current 64-bit release before creating the isolated environment.

  • Open the Python Windows releases page.
  • Select the current 64-bit Windows installer.
  • Run the downloaded installer.
  • Complete the Python installation.
  • Close Visual Studio Code after the installer finishes.

A restarted Visual Studio Code process can detect the newly installed interpreter.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to restart Visual Studio Code.
  • Return to the risk-managed-momentum workspace from the recent folders list.
  • Create a fresh terminal from the Terminal menu.
  • Check the installed Python version by running this command:
python --version

What should I see?

The terminal now prints a Python version of 3.11 or newer. That output confirms the installation is available to the workspace.

Still seeing command not found?

Confirm that the installer completed before restarting Visual Studio Code. A terminal created by an older application process may not detect the new installation.

If Windows still cannot find Python, help me diagnose the missing Python command.

Your interpreter is ready for an isolated project environment. That removes the first source of version drift.

Pin the dependency environment

A requirements file records the exact package versions used by the research tool. The local .venv keeps those packages separate from other Python projects on your computer.

  • Click the Explorer icon in the left sidebar.
  • Click the New File icon beside the risk-managed-momentum folder name.

You will see an empty filename field inside the Explorer sidebar.

  • Enter requirements.txt as the filename.
  • Create the pinned dependency file by pasting this content:
pandas==3.1.0
matplotlib==3.11.2

What does this file control?

  • The first line pins pandas to version 3.1.0 for data loading plus portfolio calculations.
  • The second line pins Matplotlib to version 3.11.2 for the later risk report.
  • The double equals syntax asks pip for those exact versions on every installation.
  • Press Ctrl+S to save requirements.txt.
  • Confirm the Explorer sidebar lists requirements.txt inside risk-managed-momentum.

Does the file look different?

Check that the filename ends in .txt. Confirm that each package pin appears on its own line.

If the editor shows a different value, help me correct my requirements file.

✔️ Awesome, I've got everything!

Your dependency pins are saved. Keep requirements.txt open while you create the isolated environment.

ⓧ I'd like to double check the full code

pandas==3.1.0
matplotlib==3.11.2

How should I compare it?

Your file should contain these two lines in this order. It should contain no extra package names or version values.

Visual Studio Code can create a workspace-local .venv from the selected interpreter. New terminals can then activate that environment for project commands.

  • Press Ctrl+Shift+P to open the Command Palette.
  • Type Python: Create Environment into the Command Palette.

The matching environment command appears in the results list.

  • Press Enter to select Python: Create Environment.
  • Select venv as the environment type.

The interpreter list can be fiddly because it may show several Python installations. Look for an entry showing version 3.11 or newer.

  • Select a Python interpreter showing version 3.11 or newer.

Visual Studio Code creates .venv inside risk-managed-momentum. You will see the selected interpreter in the Visual Studio Code status bar.

Can't select the right interpreter?

Confirm that the interpreter entry reports version 3.11 or newer. An older interpreter cannot satisfy the pinned package requirements.

If the newer installation is missing from the list, help me make VS Code detect my Python interpreter.

Install the pinned packages

A terminal that was already open may still point at the global interpreter. A fresh terminal reads the selected .venv before installing the research packages.

  • Click the trash can icon in the terminal panel to close the current terminal.

The old terminal session closes without affecting the files in your workspace.

  • Click Terminal in the top menu.
  • Select New Terminal.

The fresh terminal shows the active .venv environment in its prompt. This confirms that package installation stays inside the project.

The first installation can take one or two minutes while pip downloads the packages. Before you run these checks, do you expect the fresh terminal to use the global interpreter or .venv?

  • Verify the interpreter plus install the pinned dependencies by running these commands:
python --version
python -m pip install -r requirements.txt

What do these commands prove?

  • The first command reports the Python version active in the fresh terminal.
  • The second command asks that interpreter's pip installation to read requirements.txt.
  • A clean completion proves the isolated environment can install the exact package pins.
  • Confirm the version output reports Python 3.11 or newer.
  • Confirm pip returns to the terminal prompt without an error.

That is the reproducible foundation in place. The pinned pandas package is installed inside .venv.

The pinned Matplotlib package is installed there too. Your future backtest runs now share one controlled dependency set.

Did the installation fail?

Check that the terminal prompt shows the active .venv environment. Confirm that requirements.txt matches the two-line reference above.

If pip still stops with an error, help me debug the pinned package installation.

Your quant workspace is ready for research. Next, you will load the official momentum-decile data and prove that the parser produces a valid return matrix.

Load and Inspect Momentum Portfolios

Your isolated Python workspace in Visual Studio Code is ready. The next piece is a trustworthy matrix of daily momentum returns.

Raw research files can mix return rows with metadata. They can also contain missing-value markers. This step builds a parser that gives pandas validated decimal returns instead of silently treating those extra rows as market data.

In this step, get ready to:
  • Download the official daily momentum-decile dataset.
  • Build a parser that validates each daily return row.
  • Run the inspection path to confirm the date range and ten portfolio columns.
Download the daily momentum data

The Kenneth R. French Data Library publishes purpose-built momentum portfolio returns as a CSV archive. You will keep the vendor file unchanged so the parser remains responsible for cleaning it.

Why this data source?

The French library already groups stocks into momentum portfolios. This keeps your attention on portfolio construction instead of collecting individual stock prices.

A general market-data service such as Alpha Vantage would add an API key and data-collection work. The research dataset gives this project the exact portfolio return structure it needs.

  • Open the Kenneth R. French Data Library in your browser.
  • Find 10 Portfolios Formed on Momentum [Daily] on the page.
  • Select CSV beside that dataset.
  • Extract the downloaded archive from your Downloads folder with File Explorer.
  • Create a folder named data inside risk-managed-momentum with the VS Code Explorer sidebar.
  • Move the extracted CSV into the data folder with File Explorer.
  • Rename the extracted file to momentum_daily.csv.
  • Select the data folder in the VS Code Explorer sidebar to confirm the final path.

You should see momentum_daily.csv inside data under the risk-managed-momentum folder.

Can't see the CSV?

Check that you extracted the archive before moving the file. The data folder must contain the CSV itself instead of the downloaded archive.

Confirm the filename is exactly momentum_daily.csv. If the file still does not appear, help me check the dataset path.

Build the validated return parser

The parser needs a predictable foundation before it examines the raw rows. Its imports cover file access, tabular data, annualized calculations, and the report you will build later.

  • Create momentum_portfolio.py inside risk-managed-momentum with the VS Code Explorer sidebar.
  • Add the analysis imports to momentum_portfolio.py by copying this code:
from math import sqrt
from pathlib import Path

import matplotlib.pyplot as plt
import pandas as pd

What do these imports provide?

  • The sqrt function supports annualized risk calculations.
  • The Path class gives the script a consistent way to locate local files.
  • The matplotlib.pyplot as plt import loads the plotting interface from Matplotlib.
  • The pandas as pd import provides the table operations used by the parser.
  • Save momentum_portfolio.py.
  • Confirm momentum_portfolio.py appears beside requirements.txt in the VS Code Explorer sidebar.

Are the imports underlined?

Check that the selected interpreter still points to the active .venv from the previous step. An interpreter outside that environment may not find pandas or Matplotlib.

If the underline remains, help me check my VS Code interpreter.

Shared constants keep file locations and modeling choices in one place. They also prevent small value changes from being hidden inside portfolio calculations.

  • Add the project constants below the imports by copying this code:
DATA_PATH = Path("data/momentum_daily.csv")
RESULTS_DIR = Path("results")
TRADING_DAYS = 252
VOL_WINDOW = 63
TARGET_VOLATILITY = 0.12
MAX_EXPOSURE = 1.5
HIGH_MOMENTUM_PORTFOLIOS = ["P8", "P9", "P10"]

What do these constants control?

  • The DATA_PATH constant points to the raw CSV you just added.
  • The RESULTS_DIR constant names the output folder used by the finished report.
  • The TRADING_DAYS constant holds the annualization assumption.
  • The VOL_WINDOW constant holds the rolling risk window.
  • The TARGET_VOLATILITY constant holds the portfolio risk target.
  • The MAX_EXPOSURE constant limits total managed exposure.
  • The HIGH_MOMENTUM_PORTFOLIOS constant identifies the three sleeves used by the strategy.
  • Save momentum_portfolio.py.
  • Check the constants for red syntax underlines in the VS Code editor.

Seeing a syntax underline?

Check that every text value keeps its matching quotation marks. Confirm that the three portfolio names remain inside the square brackets.

If VS Code still flags the constants, help me compare this constants section.

Each valid data row has one eight-digit date followed by ten numeric returns. The scan skips headings and other metadata because they do not match that shape.

  • Add the first part of load_momentum_returns() below the constants by copying this code:
def load_momentum_returns(path: Path) -> pd.DataFrame:
    if not path.exists():
        raise FileNotFoundError(
            f"Missing {path}. Download the daily 10-portfolio momentum CSV, "
            "extract it, and rename it to data/momentum_daily.csv."
        )

    rows = []
    for line in path.read_text(encoding="utf-8-sig").splitlines():
        parts = [part.strip() for part in line.split(",")]
        if len(parts) != 11:
            continue

        date_text = parts[0]
        if len(date_text) != 8 or not date_text.isdigit():
            continue

        try:
            values = [float(value) for value in parts[1:]]
        except ValueError:
            continue

        if any(value in (-99.99, -999.0) for value in values):
            continue

        rows.append([date_text, *values])

How does the row scan work?

  • The path check stops the script with a specific instruction when the dataset is missing.
  • The column-count check accepts only rows containing one date field plus ten return fields.
  • The date check accepts only eight-digit values.
  • The numeric conversion rejects labels and other text rows.
  • The missing-value check removes rows containing -99.99 or -999.0.
  • Save momentum_portfolio.py.
  • Check the new function for red indentation or syntax underlines in the VS Code editor.

Does the function show an indentation problem?

Keep each row check inside the for loop. Keep each continue statement inside the condition that controls it.

If the indentation still looks wrong, help me repair the row scanner.

The scan now has a list of candidate return rows. The final parser section rejects an empty result before creating the dated return matrix.

  • Complete load_momentum_returns() directly below rows.append([date_text, *values]) by copying this code:
    if not rows:
        raise ValueError("No valid daily rows with ten portfolio returns were found.")

    columns = ["Date", *[f"P{number}" for number in range(1, 11)]]
    returns = pd.DataFrame(rows, columns=columns)
    returns["Date"] = pd.to_datetime(returns["Date"], format="%Y%m%d")
    returns = returns.set_index("Date").sort_index()
    return returns.astype(float).div(100.0)

How does the parser finish the matrix?

  • The empty-result check prevents the script from continuing with an invalid file.
  • The column list labels the ten portfolios from P1 through P10.
  • The date conversion creates a sortable date index.
  • The final division converts percentage values into decimal daily returns.
  • Save momentum_portfolio.py.
  • Check the completed function for red indentation or syntax underlines in the VS Code editor.

Is the final parser section misaligned?

Align if not rows: with rows = [] inside the function. Its indentation must place it after the loop.

If the matrix-building lines still show a problem, help me check the completed parser.

Print and verify the return matrix

An inspection path makes the parser's result visible before any portfolio logic uses it. The output should prove the number of rows, date coverage, portfolio labels, and recent return values.

  • Add the inspection-only main() below load_momentum_returns() by copying this code:
def main() -> None:
    returns = load_momentum_returns(DATA_PATH)
    print("Loaded daily momentum returns")
    print(f"Rows: {len(returns):,}")
    print(f"Date range: {returns.index.min().date()} to {returns.index.max().date()}")
    print(f"Columns: {', '.join(returns.columns)}")
    print(returns.tail(3).to_string())


if __name__ == "__main__":
    main()

What does the inspection path show?

  • The first line confirms that loading completed.
  • The row count shows how many validated daily observations survived the parser.
  • The date range shows the earliest and latest accepted observations.
  • The columns line confirms the full P1 through P10 matrix.
  • The final table displays the latest three observations for a quick visual check.
  • Save momentum_portfolio.py.

Before you run the script, which portfolio labels do you expect to see in the columns line?

  • Run the completed inspection path in the active VS Code terminal with this command:
python momentum_portfolio.py

What should I see?

You should see Loaded daily momentum returns followed by a row count and a date range. The columns line should list P1 through P10.

The final output should contain three dated rows with decimal return values. That is the data foundation locked in: your raw vendor file is now a clean ten-column return matrix.

Did the inspection stop with an error?

If the error mentions the missing path, confirm the dataset is saved as data/momentum_daily.csv inside risk-managed-momentum.

If the script reports that no valid daily rows were found, confirm you extracted the official daily momentum CSV. If you need another pair of eyes, help me diagnose the parser output.

✔️ Awesome, I've got everything!

Your script is saved. Its terminal output confirms a dated return matrix with ten momentum portfolios.

ⓧ I'd like to double check the full code

  • Compare momentum_portfolio.py with this complete inspection version:
from math import sqrt
from pathlib import Path

import matplotlib.pyplot as plt
import pandas as pd

DATA_PATH = Path("data/momentum_daily.csv")
RESULTS_DIR = Path("results")
TRADING_DAYS = 252
VOL_WINDOW = 63
TARGET_VOLATILITY = 0.12
MAX_EXPOSURE = 1.5
HIGH_MOMENTUM_PORTFOLIOS = ["P8", "P9", "P10"]


def load_momentum_returns(path: Path) -> pd.DataFrame:
    if not path.exists():
        raise FileNotFoundError(
            f"Missing {path}. Download the daily 10-portfolio momentum CSV, "
            "extract it, and rename it to data/momentum_daily.csv."
        )

    rows = []
    for line in path.read_text(encoding="utf-8-sig").splitlines():
        parts = [part.strip() for part in line.split(",")]
        if len(parts) != 11:
            continue

        date_text = parts[0]
        if len(date_text) != 8 or not date_text.isdigit():
            continue

        try:
            values = [float(value) for value in parts[1:]]
        except ValueError:
            continue

        if any(value in (-99.99, -999.0) for value in values):
            continue

        rows.append([date_text, *values])

    if not rows:
        raise ValueError("No valid daily rows with ten portfolio returns were found.")

    columns = ["Date", *[f"P{number}" for number in range(1, 11)]]
    returns = pd.DataFrame(rows, columns=columns)
    returns["Date"] = pd.to_datetime(returns["Date"], format="%Y%m%d")
    returns = returns.set_index("Date").sort_index()
    return returns.astype(float).div(100.0)


def main() -> None:
    returns = load_momentum_returns(DATA_PATH)
    print("Loaded daily momentum returns")
    print(f"Rows: {len(returns):,}")
    print(f"Date range: {returns.index.min().date()} to {returns.index.max().date()}")
    print(f"Columns: {', '.join(returns.columns)}")
    print(returns.tail(3).to_string())


if __name__ == "__main__":
    main()

Your dataset is downloaded, validated, and visible as decimal daily returns. Next, you will turn the three highest-momentum sleeves into an equal-weight portfolio and measure its unmanaged drawdown.

Build the Naive Momentum Portfolio

Your parser now turns the official daily file into validated returns for P1 through P10. Those clean momentum deciles give you a reliable base for a portfolio test.

The first portfolio uses equal weighting across the three highest-momentum sleeves. This exposes whether a simple basket still carries concentrated volatility or a deep drawdown.

In this step, get ready to:
  • Use P8, P9, plus P10 to construct an equal-weight return series.
  • Add five portfolio performance metrics.
  • Run the baseline to inspect its maximum drawdown.
Map the strategy inputs

The HIGH_MOMENTUM_PORTFOLIOS constant from earlier records which sleeves belong in the baseline. This step uses that choice to turn the momentum ranking into a portfolio.

  • Switch back to momentum_portfolio.py in the editor from earlier.
  • Locate HIGH_MOMENTUM_PORTFOLIOS = ["P8", "P9", "P10"] to confirm which three sleeves feed the strategy.

The labels P8 through P10 identify the three highest-ranked deciles in this dataset. Their same-day mean assigns one third of the portfolio to each sleeve.

Why start with equal weighting?

Equal weighting provides a transparent baseline because every sleeve receives the same capital allocation. Its results reveal the risk profile produced by the momentum selection alone.

Calculate the portfolio metrics

A backtest needs a consistent scorecard before strategies can be compared. The calculate_metrics() function turns one daily return series into five measurements.

Maximum drawdown captures the worst fall from a previous wealth peak. Historical expected shortfall summarizes the average daily return in the worst five percent of observations.

  • Place your cursor on the blank line above def main() -> None: in momentum_portfolio.py.
  • Add the metrics function by pasting this code:
def calculate_metrics(returns: pd.Series) -> dict[str, float]:
    clean_returns = returns.dropna()
    wealth = (1.0 + clean_returns).cumprod()
    drawdown = wealth.div(wealth.cummax()).sub(1.0)
    annualized_return = wealth.iloc[-1] ** (TRADING_DAYS / len(clean_returns)) - 1.0
    annualized_volatility = clean_returns.std() * sqrt(TRADING_DAYS)
    tail_threshold = clean_returns.quantile(0.05)
    expected_shortfall = clean_returns[clean_returns <= tail_threshold].mean()

    return {
        "Annualized Return": annualized_return,
        "Annualized Volatility": annualized_volatility,
        "Return / Volatility": annualized_return / annualized_volatility,
        "Maximum Drawdown": drawdown.min(),
        "Daily Expected Shortfall 5%": expected_shortfall,
    }

What does this code measure?

  • The clean_returns series removes missing observations before any calculation.
  • The wealth series compounds each daily return into the growth of one starting unit.
  • The drawdown series measures each wealth value against its previous peak.
  • The annualized calculations scale the daily results using TRADING_DAYS.
  • The tail_threshold value identifies the fifth percentile. The expected_shortfall value averages returns at or below that threshold.
  • Save momentum_portfolio.py.
  • Check that the updated script loads cleanly by running this command:
python momentum_portfolio.py

What does this command check?

Python parses the updated file before it runs the existing inspection path. Reaching the final three observations without a traceback confirms that the metrics function is syntactically valid.

Good progress. Your metric engine now loads cleanly beside the validated parser.

Seeing a traceback?

Check that calculate_metrics() begins at the far left of the file. Confirm that every line inside the function uses four spaces of indentation.

If the traceback points to a metric name or bracket, compare the return dictionary with the code above. Help me debug the calculate_metrics function.

Connect the baseline to the report

The final edit selects the three high-momentum columns through HIGH_MOMENTUM_PORTFOLIOS. It averages their same-day returns into a series named Equal Weight.

  • Find print(returns.tail(3).to_string()) inside main().
  • Place your cursor on the blank line below that inspection output.
  • Add the baseline construction plus its metrics table by pasting this code:
    high_momentum = returns[HIGH_MOMENTUM_PORTFOLIOS]
    equal_weight = high_momentum.mean(axis=1).rename("Equal Weight")
    strategies = pd.concat([equal_weight], axis=1).dropna()
    metrics = pd.DataFrame(
        {name: calculate_metrics(strategies[name]) for name in strategies.columns}
    ).T

    print("\nStrategy metrics")
    print(metrics.round(4).to_string())

How does the baseline work?

  • The high_momentum table keeps only the three columns listed in HIGH_MOMENTUM_PORTFOLIOS.
  • The row-wise mean creates the same-day portfolio return. The renamed series becomes the Equal Weight strategy.
  • The metrics table applies the same scorecard to each strategy column. This baseline currently contains one column.
  • Save momentum_portfolio.py.

Before you run the baseline, how deep do you expect the portfolio to fall below its previous peak?

  • Run the completed baseline from the active terminal with this command:
python momentum_portfolio.py

What should I see?

You'll see the inspection output followed by a Strategy metrics table. The table contains one row named Equal Weight.

The value under Maximum Drawdown is negative. That fall from a previous peak is the intended limitation exposed by this baseline.

Missing the Equal Weight row?

Check that the baseline code sits inside main() with four spaces of indentation. Confirm that calculate_metrics() remains above main().

Check that the strategy name uses the exact capitalization Equal Weight. Help me debug the missing Equal Weight metrics row.

✔️ Awesome, I've got everything!

Your file now contains the validated parser. It also contains the equal-weight metrics report.

ⓧ I'd like to double check the full code

from math import sqrt
from pathlib import Path

import matplotlib.pyplot as plt
import pandas as pd

DATA_PATH = Path("data/momentum_daily.csv")
RESULTS_DIR = Path("results")
TRADING_DAYS = 252
VOL_WINDOW = 63
TARGET_VOLATILITY = 0.12
MAX_EXPOSURE = 1.5
HIGH_MOMENTUM_PORTFOLIOS = ["P8", "P9", "P10"]


def load_momentum_returns(path: Path) -> pd.DataFrame:
    if not path.exists():
        raise FileNotFoundError(
            f"Missing {path}. Download the daily 10-portfolio momentum CSV, "
            "extract it, and rename it to data/momentum_daily.csv."
        )

    rows = []
    for line in path.read_text(encoding="utf-8-sig").splitlines():
        parts = [part.strip() for part in line.split(",")]
        if len(parts) != 11:
            continue

        date_text = parts[0]
        if len(date_text) != 8 or not date_text.isdigit():
            continue

        try:
            values = [float(value) for value in parts[1:]]
        except ValueError:
            continue

        if any(value in (-99.99, -999.0) for value in values):
            continue

        rows.append([date_text, *values])

    if not rows:
        raise ValueError("No valid daily rows with ten portfolio returns were found.")

    columns = ["Date", *[f"P{number}" for number in range(1, 11)]]
    returns = pd.DataFrame(rows, columns=columns)
    returns["Date"] = pd.to_datetime(returns["Date"], format="%Y%m%d")
    returns = returns.set_index("Date").sort_index()
    return returns.astype(float).div(100.0)


def calculate_metrics(returns: pd.Series) -> dict[str, float]:
    clean_returns = returns.dropna()
    wealth = (1.0 + clean_returns).cumprod()
    drawdown = wealth.div(wealth.cummax()).sub(1.0)
    annualized_return = wealth.iloc[-1] ** (TRADING_DAYS / len(clean_returns)) - 1.0
    annualized_volatility = clean_returns.std() * sqrt(TRADING_DAYS)
    tail_threshold = clean_returns.quantile(0.05)
    expected_shortfall = clean_returns[clean_returns <= tail_threshold].mean()

    return {
        "Annualized Return": annualized_return,
        "Annualized Volatility": annualized_volatility,
        "Return / Volatility": annualized_return / annualized_volatility,
        "Maximum Drawdown": drawdown.min(),
        "Daily Expected Shortfall 5%": expected_shortfall,
    }


def main() -> None:
    returns = load_momentum_returns(DATA_PATH)
    print("Loaded daily momentum returns")
    print(f"Rows: {len(returns):,}")
    print(f"Date range: {returns.index.min().date()} to {returns.index.max().date()}")
    print(f"Columns: {', '.join(returns.columns)}")
    print(returns.tail(3).to_string())

    high_momentum = returns[HIGH_MOMENTUM_PORTFOLIOS]
    equal_weight = high_momentum.mean(axis=1).rename("Equal Weight")
    strategies = pd.concat([equal_weight], axis=1).dropna()
    metrics = pd.DataFrame(
        {name: calculate_metrics(strategies[name]) for name in strategies.columns}
    ).T

    print("\nStrategy metrics")
    print(metrics.round(4).to_string())


if __name__ == "__main__":
    main()

Your equal-weight portfolio now turns the momentum signal into a measurable baseline. Next, you'll add lagged position sizing to manage the risk that this run exposed.

Add Lagged Risk Management

Your equal-weight baseline now reveals the portfolio's unmanaged drawdown. That result gives you a concrete risk problem to solve.

Inverse-volatility weighting gives less weight to sleeves with larger recent swings. Each estimate must move back one observation so today's allocation cannot see today's return through look-ahead bias.

In this step, get ready to:
  • Translate lagged 63-observation risk estimates into normalized sleeve weights.
  • Scale the inverse-volatility portfolio toward a 12% volatility target under an exposure cap.
  • Print aligned metrics for all three strategies plus the observed exposure range.
Set the risk budget

A risk budget defines how much history informs each estimate. It also sets the desired portfolio volatility plus the maximum permitted exposure.

  • In momentum_portfolio.py, locate TRADING_DAYS = 252 near the top of the file.
  • Add the three risk settings directly below that line by copying this code:
VOL_WINDOW = 63
TARGET_VOLATILITY = 0.12
MAX_EXPOSURE = 1.5

What do these settings control?

  • VOL_WINDOW gives every rolling estimate 63 daily observations.
  • TARGET_VOLATILITY represents the 12% annualized risk objective.
  • MAX_EXPOSURE prevents the strategy from scaling beyond 1.5 times the inverse-volatility portfolio.
  • Save momentum_portfolio.py.

Before you run the script, do you expect these settings alone to reduce the baseline drawdown?

  • Run the current baseline by using this command in the active VS Code terminal:
python momentum_portfolio.py

What should I see?

You'll see the Equal Weight metrics again. Its Maximum Drawdown remains negative.

This rerun confirms the unmanaged limitation before the new position-sizing rule addresses it.

Did the baseline stop running?

Check that the three settings sit at the file level with no indentation. Confirm that each setting uses the exact spelling shown above.

If the script still fails, help me check the new risk settings.

Build lag-safe strategy returns

A rolling estimate describes the window ending on its current row. Shifting it by one row ensures each allocation uses information available before that day's return.

  • In momentum_portfolio.py, locate the closing brace of calculate_metrics().
  • Add build_strategies() below calculate_metrics() by copying this code:
def build_strategies(
    returns: pd.DataFrame,
) -> tuple[pd.DataFrame, pd.DataFrame, pd.Series]:
    high_momentum = returns[HIGH_MOMENTUM_PORTFOLIOS]
    equal_weight = high_momentum.mean(axis=1).rename("Equal Weight")

    sleeve_volatility = (
        high_momentum.rolling(VOL_WINDOW).std().shift(1) * sqrt(TRADING_DAYS)
    )
    inverse_volatility = 1.0 / sleeve_volatility
    weights = inverse_volatility.div(inverse_volatility.sum(axis=1), axis=0)
    inverse_volatility_return = (
        (weights * high_momentum).sum(axis=1).rename("Inverse Volatility")
    )

    portfolio_volatility = (
        inverse_volatility_return.rolling(VOL_WINDOW).std().shift(1)
        * sqrt(TRADING_DAYS)
    )
    exposure = (TARGET_VOLATILITY / portfolio_volatility).clip(upper=MAX_EXPOSURE)
    risk_managed = (inverse_volatility_return * exposure).rename("Risk Managed")

    strategies = pd.concat(
        [equal_weight, inverse_volatility_return, risk_managed], axis=1
    ).dropna()
    weights = weights.loc[strategies.index]
    exposure = exposure.loc[strategies.index].rename("Risk Managed Exposure")
    return strategies, weights, exposure

What does this function do?

  • sleeve_volatility annualizes each sleeve's rolling standard deviation. The shift(1) call keeps the estimate one observation behind the return it sizes.
  • inverse_volatility turns lower estimated risk into a larger raw allocation. Dividing by the row total converts those allocations into normalized daily weights.
  • portfolio_volatility applies the same lagging rule to the combined inverse-volatility return series.
  • exposure divides the target by estimated portfolio volatility. The upper clip enforces the total exposure cap.
  • dropna() removes the shared warm-up period. The aligned index then trims the weight history plus the exposure history to the same dates.

Volatility targeting increases exposure when estimated risk is below the target. It reduces exposure when estimated risk rises.

  • Inside main(), locate print(returns.tail(3).to_string()).
  • Select the existing equal-weight calculation through the current metrics output.
  • Replace the selected code by copying this block below the inspection output:
    strategies, weights, exposure = build_strategies(returns)
    metrics = pd.DataFrame(
        {name: calculate_metrics(strategies[name]) for name in strategies.columns}
    ).T

    print("\nStrategy metrics")
    print(metrics.round(4).to_string())
    print(
        f"\nExposure range: {exposure.min():.3f} to {exposure.max():.3f}"
    )

How does the comparison stay consistent?

The dictionary comprehension applies calculate_metrics() to each aligned strategy column. Every metrics row therefore covers the same dates.

The final print statement exposes the smallest observed allocation multiplier plus the largest one. That range makes the cap directly auditable from the terminal.

✔️ Awesome, I've got everything!

  • Save momentum_portfolio.py.

ⓧ I'd like to double check the full code

from math import sqrt
from pathlib import Path

import pandas as pd

DATA_PATH = Path("data/momentum_daily.csv")
TRADING_DAYS = 252
VOL_WINDOW = 63
TARGET_VOLATILITY = 0.12
MAX_EXPOSURE = 1.5
HIGH_MOMENTUM_PORTFOLIOS = ["P8", "P9", "P10"]


def load_momentum_returns(path: Path) -> pd.DataFrame:
    if not path.exists():
        raise FileNotFoundError(
            f"Missing {path}. Download the daily 10-portfolio momentum CSV, "
            "extract it, and rename it to data/momentum_daily.csv."
        )

    rows = []
    for line in path.read_text(encoding="utf-8-sig").splitlines():
        parts = [part.strip() for part in line.split(",")]
        if len(parts) != 11:
            continue

        date_text = parts[0]
        if len(date_text) != 8 or not date_text.isdigit():
            continue

        try:
            values = [float(value) for value in parts[1:]]
        except ValueError:
            continue

        if any(value in (-99.99, -999.0) for value in values):
            continue

        rows.append([date_text, *values])

    if not rows:
        raise ValueError("No valid daily rows with ten portfolio returns were found.")

    columns = ["Date", *[f"P{number}" for number in range(1, 11)]]
    returns = pd.DataFrame(rows, columns=columns)
    returns["Date"] = pd.to_datetime(returns["Date"], format="%Y%m%d")
    returns = returns.set_index("Date").sort_index()
    return returns.astype(float).div(100.0)


def calculate_metrics(returns: pd.Series) -> dict[str, float]:
    clean_returns = returns.dropna()
    wealth = (1.0 + clean_returns).cumprod()
    drawdown = wealth.div(wealth.cummax()).sub(1.0)
    annualized_return = wealth.iloc[-1] ** (TRADING_DAYS / len(clean_returns)) - 1.0
    annualized_volatility = clean_returns.std() * sqrt(TRADING_DAYS)
    tail_threshold = clean_returns.quantile(0.05)
    expected_shortfall = clean_returns[clean_returns <= tail_threshold].mean()

    return {
        "Annualized Return": annualized_return,
        "Annualized Volatility": annualized_volatility,
        "Return / Volatility": annualized_return / annualized_volatility,
        "Maximum Drawdown": drawdown.min(),
        "Daily Expected Shortfall 5%": expected_shortfall,
    }


def build_strategies(
    returns: pd.DataFrame,
) -> tuple[pd.DataFrame, pd.DataFrame, pd.Series]:
    high_momentum = returns[HIGH_MOMENTUM_PORTFOLIOS]
    equal_weight = high_momentum.mean(axis=1).rename("Equal Weight")

    sleeve_volatility = (
        high_momentum.rolling(VOL_WINDOW).std().shift(1) * sqrt(TRADING_DAYS)
    )
    inverse_volatility = 1.0 / sleeve_volatility
    weights = inverse_volatility.div(inverse_volatility.sum(axis=1), axis=0)
    inverse_volatility_return = (
        (weights * high_momentum).sum(axis=1).rename("Inverse Volatility")
    )

    portfolio_volatility = (
        inverse_volatility_return.rolling(VOL_WINDOW).std().shift(1)
        * sqrt(TRADING_DAYS)
    )
    exposure = (TARGET_VOLATILITY / portfolio_volatility).clip(upper=MAX_EXPOSURE)
    risk_managed = (inverse_volatility_return * exposure).rename("Risk Managed")

    strategies = pd.concat(
        [equal_weight, inverse_volatility_return, risk_managed], axis=1
    ).dropna()
    weights = weights.loc[strategies.index]
    exposure = exposure.loc[strategies.index].rename("Risk Managed Exposure")
    return strategies, weights, exposure


def main() -> None:
    returns = load_momentum_returns(DATA_PATH)
    print("Loaded daily momentum returns")
    print(f"Rows: {len(returns):,}")
    print(f"Date range: {returns.index.min().date()} to {returns.index.max().date()}")
    print(f"Columns: {', '.join(returns.columns)}")
    print(returns.tail(3).to_string())

    strategies, weights, exposure = build_strategies(returns)
    metrics = pd.DataFrame(
        {name: calculate_metrics(strategies[name]) for name in strategies.columns}
    ).T

    print("\nStrategy metrics")
    print(metrics.round(4).to_string())
    print(
        f"\nExposure range: {exposure.min():.3f} to {exposure.max():.3f}"
    )


if __name__ == "__main__":
    main()

What should I compare?

Confirm that your file contains the three risk settings plus build_strategies(). Check that both rolling volatility estimates use shift(1) before they affect an allocation.

Verify the strategy comparison

Before you run the finished script, where do you expect exposure to stop when the volatility target requests a larger multiplier?

  • Run the completed comparison in the active VS Code terminal by using this command:
python momentum_portfolio.py

What should I see?

You'll see metric rows named Equal Weight, Inverse Volatility, plus Risk Managed.

You'll also see an Exposure range whose maximum is no greater than 1.500.

That closes the core risk-control gap. Your portfolio now sizes from lagged estimates while respecting its exposure ceiling.

Missing rows or seeing excess exposure?

If only Equal Weight appears, confirm that main() calls build_strategies(returns).

If exposure exceeds 1.500, compare the clip(upper=MAX_EXPOSURE) line with the full-code reference.

If the output still looks wrong, help me debug the lagged strategy comparison.

Your lag-safe portfolio comparison is working. Next, you'll turn its metrics plus histories into an auditable research report.

Produce the Portfolio Risk Report

Three metric rows summarize the strategies. They do not show when losses accumulated or how exposure changed through time.

This step turns the backtest into portable evidence. You will export auditable CSV histories. You will also create a three-panel report with Matplotlib.

In this step, get ready to:
  • Export four auditable CSV research outputs.
  • Create a three-panel portfolio risk chart.
  • Compare drawdown with 5% historical expected shortfall.
Create auditable strategy histories

A headline metric becomes easier to audit when you can trace it back to a dated series. The report needs cumulative equity for growth plus drawdown for losses from each prior peak.

  • Find these constants near the top of momentum_portfolio.py:
DATA_PATH = Path("data/momentum_daily.csv")
TRADING_DAYS = 252

What are you locating?

The data path marks the start of the script's project-level configuration. The results path belongs beside it because every export uses the same destination.

  • Add RESULTS_DIR directly below DATA_PATH so the constants look like this:
DATA_PATH = Path("data/momentum_daily.csv")
RESULTS_DIR = Path("results")
TRADING_DAYS = 252

Why define the path once?

The RESULTS_DIR constant gives every exported artifact one destination. A single path also prevents the CSV files from drifting away from the chart.

  • Find return strategies, weights, exposure at the end of build_strategies().
  • Add the following function directly below it:
def build_histories(strategies: pd.DataFrame) -> pd.DataFrame:
    equity = (1.0 + strategies).cumprod()
    drawdowns = equity.div(equity.cummax()).sub(1.0)
    equity.columns = [f"{name} Equity" for name in equity.columns]
    drawdowns.columns = [f"{name} Drawdown" for name in drawdowns.columns]
    return pd.concat([equity, drawdowns], axis=1)

What does this code do?

  • The cumulative product converts each strategy's daily returns into the growth of one invested unit.
  • The running maximum records each strategy's previous wealth peak.
  • The drawdown series measures every decline relative to that peak.
  • The renamed columns keep equity values separate from drawdown values in one export.
  • Save momentum_portfolio.py.
  • Confirm the existing strategy calculations still run by running this command:
python momentum_portfolio.py

What should you see?

You should still see metrics for Equal Weight, Inverse Volatility, and Risk Managed. The exposure maximum should remain no greater than 1.500.

Seeing an error after adding the function?

Check that build_histories() starts at the left edge of the file. Confirm that the function sits below the final return from build_strategies().

If the error remains, help me check the build_histories function.

The history table now has a clear shape. The next edit saves it beside the metrics and daily allocation records.

  • Find the line ending with ).T inside main().
  • Add the following export block directly below it:
    histories = build_histories(strategies)

    RESULTS_DIR.mkdir(exist_ok=True)
    metrics.to_csv(RESULTS_DIR / "metrics.csv")
    histories.to_csv(RESULTS_DIR / "equity_and_drawdown.csv")
    weights.to_csv(RESULTS_DIR / "weights.csv")
    exposure.to_csv(RESULTS_DIR / "exposure.csv")

What does this export block do?

  • The history builder derives cumulative wealth and drawdown from the aligned strategy returns.
  • The results directory is created when it does not already exist.
  • The four exports preserve metrics, histories, allocation weights, and managed exposure as separate research artifacts.
  • Save momentum_portfolio.py.
  • Create the four CSV outputs by running this command:
python momentum_portfolio.py

What should you see?

The terminal should still print all three strategy rows. The VS Code file sidebar should now contain a results folder with four CSV files.

The files are metrics.csv, equity_and_drawdown.csv, weights.csv, and exposure.csv.

Missing one of the CSV files?

Confirm that every export line is indented inside main(). Check that each filename matches the export block exactly.

If the folder is still incomplete, help me debug the missing portfolio exports.

Build the three-panel risk chart

A chart exposes the path hidden inside summary statistics. Placing wealth, drawdown, and exposure on aligned panels makes each risk decision visible on the same timeline.

  • Find build_histories() in momentum_portfolio.py.
  • Add the following plotting function directly below it:
def plot_results(
    strategies: pd.DataFrame,
    exposure: pd.Series,
    output_path: Path,
) -> None:
    equity = (1.0 + strategies).cumprod()
    drawdowns = equity.div(equity.cummax()).sub(1.0)

    fig, axes = plt.subplots(3, 1, figsize=(11, 10), sharex=True)
    for name in equity.columns:
        axes[0].plot(equity.index, equity[name], label=name)
        axes[1].plot(drawdowns.index, drawdowns[name], label=name)

    axes[2].plot(exposure.index, exposure, label=exposure.name, color="black")

    axes[0].set_title("Growth of One Unit")
    axes[0].set_ylabel("Wealth")
    axes[1].set_title("Drawdown")
    axes[1].set_ylabel("Drawdown")
    axes[2].set_title("Risk-Managed Exposure")
    axes[2].set_ylabel("Exposure")

    for axis in axes:
        axis.grid(True, alpha=0.3)
        axis.legend()

    fig.tight_layout()
    fig.savefig(output_path, dpi=150, bbox_inches="tight")

How does the chart work?

  • The first panel compares cumulative wealth across the three aligned strategies.
  • The second panel plots each strategy's decline from its prior wealth peak.
  • The third panel shows the daily scale applied to the risk-managed portfolio.
  • The shared date axis lets you connect an exposure change with the return path around it.

The plotting function needs one call from main(). The final terminal message also needs to confirm that the complete research package was saved.

  • Find the export and print section at the bottom of main().
  • Use this current section as your reference:
    exposure.to_csv(RESULTS_DIR / "exposure.csv")

    print("\nStrategy metrics")
    print(metrics.round(4).to_string())
    print(
        f"\nExposure range: {exposure.min():.3f} to {exposure.max():.3f}"
    )

What is changing here?

This section already saves the exposure history before printing the strategy summary. You will connect the chart export to the same output sequence.

  • Replace that section with the completed version below:
    exposure.to_csv(RESULTS_DIR / "exposure.csv")
    plot_results(strategies, exposure, RESULTS_DIR / "risk_managed_momentum.png")

    print("\nStrategy metrics")
    print(metrics.round(4).to_string())
    print(
        f"\nExposure range: {exposure.min():.3f} to {exposure.max():.3f}"
    )
    print("Saved research outputs to results")

What does the completed section add?

The plotting call sends the aligned strategies plus managed exposure into the report function. The output path saves the figure as results/risk_managed_momentum.png.

The final print statement gives the run a clear completion signal. That signal confirms the script reached the end of the export workflow.

  • Save momentum_portfolio.py.

✔️ Awesome, I've got everything!

Your script now contains the history builder, chart builder, four CSV exports, and PNG export.

ⓧ I'd like to double check the full code

from math import sqrt
from pathlib import Path

import matplotlib.pyplot as plt
import pandas as pd

DATA_PATH = Path("data/momentum_daily.csv")
RESULTS_DIR = Path("results")
TRADING_DAYS = 252
VOL_WINDOW = 63
TARGET_VOLATILITY = 0.12
MAX_EXPOSURE = 1.5
HIGH_MOMENTUM_PORTFOLIOS = ["P8", "P9", "P10"]


def load_momentum_returns(path: Path) -> pd.DataFrame:
    if not path.exists():
        raise FileNotFoundError(
            f"Missing {path}. Download the daily 10-portfolio momentum CSV, "
            "extract it, and rename it to data/momentum_daily.csv."
        )

    rows = []
    for line in path.read_text(encoding="utf-8-sig").splitlines():
        parts = [part.strip() for part in line.split(",")]
        if len(parts) != 11:
            continue

        date_text = parts[0]
        if len(date_text) != 8 or not date_text.isdigit():
            continue

        try:
            values = [float(value) for value in parts[1:]]
        except ValueError:
            continue

        if any(value in (-99.99, -999.0) for value in values):
            continue

        rows.append([date_text, *values])

    if not rows:
        raise ValueError("No valid daily rows with ten portfolio returns were found.")

    columns = ["Date", *[f"P{number}" for number in range(1, 11)]]
    returns = pd.DataFrame(rows, columns=columns)
    returns["Date"] = pd.to_datetime(returns["Date"], format="%Y%m%d")
    returns = returns.set_index("Date").sort_index()
    return returns.astype(float).div(100.0)


def calculate_metrics(returns: pd.Series) -> dict[str, float]:
    clean_returns = returns.dropna()
    wealth = (1.0 + clean_returns).cumprod()
    drawdown = wealth.div(wealth.cummax()).sub(1.0)
    annualized_return = wealth.iloc[-1] ** (TRADING_DAYS / len(clean_returns)) - 1.0
    annualized_volatility = clean_returns.std() * sqrt(TRADING_DAYS)
    tail_threshold = clean_returns.quantile(0.05)
    expected_shortfall = clean_returns[clean_returns <= tail_threshold].mean()

    return {
        "Annualized Return": annualized_return,
        "Annualized Volatility": annualized_volatility,
        "Return / Volatility": annualized_return / annualized_volatility,
        "Maximum Drawdown": drawdown.min(),
        "Daily Expected Shortfall 5%": expected_shortfall,
    }


def build_strategies(
    returns: pd.DataFrame,
) -> tuple[pd.DataFrame, pd.DataFrame, pd.Series]:
    high_momentum = returns[HIGH_MOMENTUM_PORTFOLIOS]
    equal_weight = high_momentum.mean(axis=1).rename("Equal Weight")

    sleeve_volatility = (
        high_momentum.rolling(VOL_WINDOW).std().shift(1) * sqrt(TRADING_DAYS)
    )
    inverse_volatility = 1.0 / sleeve_volatility
    weights = inverse_volatility.div(inverse_volatility.sum(axis=1), axis=0)
    inverse_volatility_return = (
        (weights * high_momentum).sum(axis=1).rename("Inverse Volatility")
    )

    portfolio_volatility = (
        inverse_volatility_return.rolling(VOL_WINDOW).std().shift(1)
        * sqrt(TRADING_DAYS)
    )
    exposure = (TARGET_VOLATILITY / portfolio_volatility).clip(upper=MAX_EXPOSURE)
    risk_managed = (inverse_volatility_return * exposure).rename("Risk Managed")

    strategies = pd.concat(
        [equal_weight, inverse_volatility_return, risk_managed], axis=1
    ).dropna()
    weights = weights.loc[strategies.index]
    exposure = exposure.loc[strategies.index].rename("Risk Managed Exposure")
    return strategies, weights, exposure


def build_histories(strategies: pd.DataFrame) -> pd.DataFrame:
    equity = (1.0 + strategies).cumprod()
    drawdowns = equity.div(equity.cummax()).sub(1.0)
    equity.columns = [f"{name} Equity" for name in equity.columns]
    drawdowns.columns = [f"{name} Drawdown" for name in drawdowns.columns]
    return pd.concat([equity, drawdowns], axis=1)


def plot_results(
    strategies: pd.DataFrame,
    exposure: pd.Series,
    output_path: Path,
) -> None:
    equity = (1.0 + strategies).cumprod()
    drawdowns = equity.div(equity.cummax()).sub(1.0)

    fig, axes = plt.subplots(3, 1, figsize=(11, 10), sharex=True)
    for name in equity.columns:
        axes[0].plot(equity.index, equity[name], label=name)
        axes[1].plot(drawdowns.index, drawdowns[name], label=name)

    axes[2].plot(exposure.index, exposure, label=exposure.name, color="black")

    axes[0].set_title("Growth of One Unit")
    axes[0].set_ylabel("Wealth")
    axes[1].set_title("Drawdown")
    axes[1].set_ylabel("Drawdown")
    axes[2].set_title("Risk-Managed Exposure")
    axes[2].set_ylabel("Exposure")

    for axis in axes:
        axis.grid(True, alpha=0.3)
        axis.legend()

    fig.tight_layout()
    fig.savefig(output_path, dpi=150, bbox_inches="tight")


def main() -> None:
    returns = load_momentum_returns(DATA_PATH)
    print("Loaded daily momentum returns")
    print(f"Rows: {len(returns):,}")
    print(f"Date range: {returns.index.min().date()} to {returns.index.max().date()}")
    print(f"Columns: {', '.join(returns.columns)}")
    print(returns.tail(3).to_string())

    strategies, weights, exposure = build_strategies(returns)
    metrics = pd.DataFrame(
        {name: calculate_metrics(strategies[name]) for name in strategies.columns}
    ).T
    histories = build_histories(strategies)

    RESULTS_DIR.mkdir(exist_ok=True)
    metrics.to_csv(RESULTS_DIR / "metrics.csv")
    histories.to_csv(RESULTS_DIR / "equity_and_drawdown.csv")
    weights.to_csv(RESULTS_DIR / "weights.csv")
    exposure.to_csv(RESULTS_DIR / "exposure.csv")
    plot_results(strategies, exposure, RESULTS_DIR / "risk_managed_momentum.png")

    print("\nStrategy metrics")
    print(metrics.round(4).to_string())
    print(
        f"\nExposure range: {exposure.min():.3f} to {exposure.max():.3f}"
    )
    print("Saved research outputs to results")


if __name__ == "__main__":
    main()

What should you compare?

Use this reference to check function order, indentation, filenames, and the final call sequence. Every rolling volatility estimate remains shifted before allocation.

Run and review the report

The completed run should now produce both machine-readable research files and a presentation-ready chart. The chart provides the quickest visual audit of growth, losses, and managed exposure.

Before you run the script, which strategy do you expect to show the smallest drawdown?

  • Generate the completed research package by running this command:
python momentum_portfolio.py

What should you see?

The terminal should print three metrics rows named Equal Weight, Inverse Volatility, and Risk Managed. It should finish with Saved research outputs to results.

The results folder should contain four CSV files plus risk_managed_momentum.png.

Report not created?

Check that the plot_results() call is inside main(). Confirm that its output path uses RESULTS_DIR.

If the script stops before the completion message, help me debug the portfolio report export.

  • Select results/risk_managed_momentum.png in the VS Code file sidebar to confirm the report has three panels.
  • Select results/metrics.csv to compare the three strategy rows.
  • Compare Maximum Drawdown across the three strategies.
  • Compare Daily Expected Shortfall 5% across the three strategies.

Judge the risk trade-off

Annualized return captures only one part of the result. Maximum drawdown shows the deepest peak-to-trough loss.

Daily expected shortfall estimates the average return among the worst 5% of days. Together, these measures show whether the managed strategy improved the loss profile.

That completes the evidence layer: your momentum backtest now produces traceable data plus a chart that makes the risk trade-off visible.

Secret mission

Add a Fast Volatility Brake

Your current risk control uses a 63-observation volatility estimate. A sudden shock can move faster than that slow window. Add a defensive brake that responds to the larger of lagged 21-observation risk or lagged 63-observation risk.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your Windows computer with no recurring charges. Decide whether to keep the workspace, pause your work, or delete every local resource.

Resources you used:

  • The local .venv virtual environment inside risk-managed-momentum.
  • The downloaded research dataset at data/momentum_daily.csv.
  • The project script at momentum_portfolio.py.
  • The pinned dependency file at requirements.txt.
  • Four CSV outputs at results/metrics.csv, results/equity_and_drawdown.csv, results/weights.csv, and results/exposure.csv.
  • The portfolio risk report at results/risk_managed_momentum.png.

Keep everything running

No action is needed. Choose this option if you want to continue the quant research series or revisit your portfolio results.

  • Preserve the complete risk-managed-momentum folder as Project 1 of your quant research series.
  • Reuse the four result CSV files in the stress-testing follow-on project.
  • Keep results/risk_managed_momentum.png as a visual comparison of all four strategies.
  • Expect no recurring charges from keeping these local resources.

Pause - I'll come back to this later

Pausing closes the active workspace while leaving every file ready for later. Your local environment remains free to keep.

  • Close Visual Studio Code.
  • Leave the risk-managed-momentum folder in its current location.
  • Return later by reopening risk-managed-momentum in Visual Studio Code.
  • Use the existing .venv when you resume your research.

Delete - I don't want to use this again

Deleting the parent folder can feel drastic. The deletion is contained to the local environment, dataset, code, and reports inside risk-managed-momentum.

  • Close Visual Studio Code.
  • Use Windows Search to open File Explorer.
  • Navigate to the location that contains risk-managed-momentum.
  • Select the risk-managed-momentum folder.
  • Choose Delete in File Explorer.
  • Use Windows Search to open the Recycle Bin.
  • Select the risk-managed-momentum folder inside the Recycle Bin.
  • Permanently delete the selected folder from the Recycle Bin.
  • Confirm the permanent deletion if Windows asks for approval.

You should no longer see risk-managed-momentum in its original location or the Recycle Bin. That is the full project cleanup done.

Nice Work!

Nice Work!

Strong finish! You've built Project 1 of the quant-research series: a command-line momentum portfolio tool with auditable risk controls.

You've learned how to:

  • Turn the official daily file of momentum deciles into a validated return matrix. Exclude metadata rows. Reject documented missing values before analysis.
  • Construct an equal-weight portfolio from the three high-momentum sleeves. Add lagged inverse-volatility weighting to control sleeve concentration. Apply volatility targeting under a 1.5 exposure cap without introducing look-ahead bias.
  • Compare annualized return against annualized volatility. Quantify maximum drawdown. Estimate historical expected shortfall. Export an auditable research package with a three-panel risk report.
  • Complete the optional Secret Mission by adding a fast volatility brake. Size exposure from the larger of the lagged 21-day estimate or lagged 63-day estimate.

Ready to quiz yourself?