Portfolio VaR Backtester in Python

Build a CLI tool that computes and backtests portfolio Value-at-Risk.

Introduction

30 Second Summary

Every investor wants to know how much their portfolio could lose tomorrow. Banks and hedge funds answer this question every day using a metric called Value-at-Risk.

In this project, you will build a command-line Python tool that downloads real stock data and computes Value-at-Risk using three industry methods. It then stress-tests those predictions against actual market history to reveal which model holds up and which one breaks down.

What You'll Build

You run one command and watch your tool download real stock prices, calculate three different risk estimates, and tell you exactly which model you can trust.

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

  • You run one command and see three risk estimates appear side by side, each from a different method: Historical simulation, Parametric VaR, and Monte Carlo simulation.
  • A Kupiec backtest that checks each model against years of real market data, printing a clear pass or fail verdict for every method.
  • A tested, modular codebase published to GitHub with a feature branch and Pull Request that any interviewer can review.
  • Secret Mission: A color-coded matplotlib chart that highlights every VaR breach day in red, making backtest results visible at a glance.

Are there any prerequisites?

You should be comfortable with basic Python concepts like functions, imports, and lists. You will also need VS Code installed and a GitHub account.

Before We Start

Before We Start...

Before we write any code, let's get clear on what you're about to build and why it matters. You'll create a Python tool that calculates portfolio risk three different ways, then stress-tests those predictions against real market data to find out which models actually hold up.

Set Up the Repository and Environment

Before writing any risk calculations, you need a solid project foundation. Without version-controlled dependencies and a clear folder structure, you end up with an unstructured mess that nobody can reproduce.

In this step, you'll create a Git repository, set up a Python virtual environment with pinned dependencies, and scaffold every file your VaR tool will need.

In this step, get ready to:
  • Create the project folder and initialize a Git repository.
  • Set up a virtual environment and install all dependencies.
  • Build the project file structure and make the first commit.
Create the project folder and initialize Git
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder, move into it, and initialize a Git repository by running these commands:
mkdir portfolio-var-backtester
cd portfolio-var-backtester
git init

You should see a message that says Initialized empty Git repository followed by the path to your new folder.

Seeing 'command not found' for git?

Confirm Git is installed by running git --version. You should see 2.54.0 or similar. If the command is not recognized, reinstall Git from the official site.

Help me fix a 'git not found' error on my machine.

  • Open this folder in VS Code by running:
code .

VS Code opens with the portfolio-var-backtester folder loaded in the Explorer sidebar.

code . not recognized?

Press Cmd+Space (macOS) or the Windows key (Windows), type Visual Studio Code, and press Enter to open it manually.

Then click File in the top menu bar, click Open Folder, navigate to your Desktop, select the portfolio-var-backtester folder, and click Open.

Install dependencies in a virtual environment

A virtual environment keeps this project's packages separate from every other Python project on your machine. You'll create one, then install the five libraries the VaR tool needs.

  • In VS Code's Explorer sidebar, hover over the folder name and click the New File icon.
  • Name the file requirements.txt and press Enter.
  • Paste the following content into the file:
yfinance==1.5.2
numpy==2.4.6
pandas==3.0.5
scipy==1.16.2
pytest==9.1.1

What are these packages?

  • yfinance downloads real stock price data from Yahoo Finance.
  • NumPy and pandas handle numerical arrays and time-series data.
  • SciPy provides statistical functions like the normal distribution and chi-squared test.
  • pytest is a testing framework you'll use to verify your calculations.
  • Save requirements.txt with Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you've saved the file before moving on.

ⓧ I'd like to double check the full code

Here's the complete requirements.txt for comparison:

yfinance==1.5.2
numpy==2.4.6
pandas==3.0.5
scipy==1.16.2
pytest==9.1.1

Now create the virtual environment and install these packages. Switch back to your terminal (it should still be in the portfolio-var-backtester folder).

  • Create the virtual environment by running:

macOS

python3 -m venv venv

Windows

python -m venv venv
  • Activate the virtual environment by running:

macOS

source venv/bin/activate

Windows

.\venv\Scripts\Activate.ps1

Your terminal prompt now shows (venv) at the beginning, confirming the virtual environment is active.

Windows: seeing a script execution error?

PowerShell's default execution policy can block the activation script. Run the following command first, then try activating again:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

Help me activate my Python virtual environment on Windows.

  • Install all five packages by running:
pip install -r requirements.txt

You should see pip downloading and installing each package. The final line confirms all five packages were successfully installed.

Packages failing to install?

Make sure your virtual environment is active. You should see (venv) in your terminal prompt. If not, run the activation command above again.

Also confirm you saved requirements.txt and that your terminal is in the portfolio-var-backtester folder.

Help me debug a pip install failure.

Create the file structure and make the first commit

Your VaR tool is split into a var_tool/ package for the core logic and a tests/ directory for unit tests. Creating all the files now means each piece has a home before you start coding.

  • Create the directories and empty files by running these commands:

macOS

mkdir var_tool tests
touch var_tool/__init__.py var_tool/data.py var_tool/var.py var_tool/backtest.py
touch tests/__init__.py tests/test_var.py
touch main.py

Windows

mkdir var_tool
mkdir tests
New-Item -ItemType File var_tool/__init__.py
New-Item -ItemType File var_tool/data.py
New-Item -ItemType File var_tool/var.py
New-Item -ItemType File var_tool/backtest.py
New-Item -ItemType File tests/__init__.py
New-Item -ItemType File tests/test_var.py
New-Item -ItemType File main.py
  • Confirm the files exist by running:
ls var_tool/ tests/ main.py requirements.txt

You should see __init__.py, data.py, var.py, and backtest.py inside var_tool/. You should also see __init__.py and test_var.py inside tests/.

Why are some files empty?

The __init__.py files tell Python that var_tool/ and tests/ are importable packages. They can stay empty.

The other files (data.py, var.py, backtest.py, test_var.py, main.py) are placeholders you'll fill with code in upcoming steps.

  • Stage all files and make your first commit by running these commands:
git add .
git commit -m "Initial project structure and dependencies"

Git confirms the commit with a short summary showing how many files were created.

Git asking for your name and email?

If this is your first time using Git, it prompts you to configure your identity. Run these two commands (with your own name and email), then retry the commit:

git config --global user.name "Your Name"
git config --global user.email "you@example.com"

Before you wrap up, what do you think will happen when you run python -m pytest --version? Will it print a version number, or will it fail?

  • Verify your environment is working by running:
python -m pytest --version

You should see pytest's version number printed (9.1.1). This confirms that your virtual environment is active and all dependencies installed correctly.

Seeing 'No module named pytest'?

This means your virtual environment is not active. Check for (venv) at the start of your terminal prompt. If it's missing, run the activation command for your platform again, then re-run the version check.

Help me fix a missing pytest module error.

Your project skeleton is locked in and version-controlled. Next up, you'll fill data.py with real market data logic and see actual stock prices flowing through your tool.

Fetch Market Data and Compute Returns

In the previous step, you built the project skeleton and installed all the dependencies your VaR tool needs. Now it is time to fill those empty files with real functionality.

Before you can calculate risk, you need real price data transformed into daily returns. In this step, you will download historical stock prices from Yahoo Finance using yfinance, turn them into daily percentage returns, and combine them into a single portfolio return series. Seeing actual numbers printed confirms your data pipeline works end to end.

In this step, get ready to:
  • Build a data module that downloads stock prices and computes portfolio returns.
  • Wire everything into main.py and see real market data printed in your terminal.
  • Commit your progress to Git.
Build the data module

The data module is responsible for downloading raw stock prices from the internet and transforming them into the portfolio returns that the rest of the tool will analyze. You will build it in three functions, starting with the one that fetches prices.

  • Open var_tool/data.py in VS Code and add the imports and the first function by pasting the following code:
import yfinance as yf
import numpy as np
import pandas as pd


def fetch_prices(tickers: list[str], start: str, end: str) -> pd.DataFrame:
    """Download adjusted close prices for a list of tickers."""
    data = yf.download(tickers, start=start, end=end, auto_adjust=True)
    prices = data["Close"]
    prices = prices.dropna()
    return prices

What does this code do?

This function connects to Yahoo Finance and pulls historical stock prices for every ticker you pass in.

  • yf.download() fetches daily price data for the given tickers between start and end dates.
  • auto_adjust=True means the prices are already corrected for stock splits and dividends.
  • data["Close"] extracts just the closing prices, giving you a DataFrame with one column per stock.
  • dropna() removes any rows with missing data so the math downstream stays clean.
  • Save data.py.
  • Confirm the module is importable by running this command in your terminal:
python -c "from var_tool.data import fetch_prices; print('Import OK')"

You should see Import OK printed with no errors.

Seeing a ModuleNotFoundError?

  • Make sure your virtual environment is still activated. You should see (venv) at the start of your terminal prompt.
  • Check that var_tool/__init__.py exists. Python needs this file to treat the folder as a package.
  • Make sure you are running the command from inside the portfolio-var-backtester folder, not from somewhere else.

Can't figure out the import error? Help me debug why Python can't find my var_tool.data module.

Now add the two functions that transform raw prices into portfolio returns.

  • In var_tool/data.py, paste the following code below the fetch_prices function:

def compute_returns(prices: pd.DataFrame) -> pd.DataFrame:
    """Compute simple daily returns from price data."""
    returns = prices.pct_change().dropna()
    return returns


def compute_portfolio_returns(
    returns: pd.DataFrame, weights: list[float]
) -> pd.Series:
    """Compute weighted portfolio returns."""
    weights_array = np.array(weights)
    portfolio_returns = returns.dot(weights_array)
    portfolio_returns.name = "portfolio"
    return portfolio_returns

What do these functions do?

  • compute_returns() uses Pandas' pct_change() method to calculate the daily percentage change between consecutive closing prices. A 2% daily return means the portfolio gained 2% that day.
  • compute_portfolio_returns() takes each stock's individual returns and combines them using your chosen weights. The .dot() method multiplies each stock's return by its weight and sums them into a single number per day.
  • Save data.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved var_tool/data.py and let's move on.

ⓧ I'd like to double check the full code

Here is the complete var_tool/data.py file. Make sure yours matches exactly:

import yfinance as yf
import numpy as np
import pandas as pd


def fetch_prices(tickers: list[str], start: str, end: str) -> pd.DataFrame:
    """Download adjusted close prices for a list of tickers."""
    data = yf.download(tickers, start=start, end=end, auto_adjust=True)
    prices = data["Close"]
    prices = prices.dropna()
    return prices


def compute_returns(prices: pd.DataFrame) -> pd.DataFrame:
    """Compute simple daily returns from price data."""
    returns = prices.pct_change().dropna()
    return returns


def compute_portfolio_returns(
    returns: pd.DataFrame, weights: list[float]
) -> pd.Series:
    """Compute weighted portfolio returns."""
    weights_array = np.array(weights)
    portfolio_returns = returns.dot(weights_array)
    portfolio_returns.name = "portfolio"
    return portfolio_returns
Wire up the entry point and run the tool

The data module is ready, but nothing calls it yet. Now you will configure main.py with a real portfolio and see actual market data printed to the terminal.

  • Open main.py in VS Code and paste the following code:
import numpy as np

from var_tool.data import fetch_prices, compute_returns, compute_portfolio_returns


def main():
    # Portfolio configuration
    tickers = ["AAPL", "MSFT", "GOOGL"]
    weights = [0.4, 0.35, 0.25]
    start_date = "2020-01-01"
    end_date = "2024-12-31"

    # Fetch data and compute returns
    print("Fetching price data...")
    prices = fetch_prices(tickers, start_date, end_date)
    returns = compute_returns(prices)
    portfolio_returns = compute_portfolio_returns(returns, weights)
    print(f"Loaded {len(portfolio_returns)} days of portfolio returns.\n")
    print(portfolio_returns.describe())


if __name__ == "__main__":
    main()

What does this code do?

  • The portfolio is configured with three stocks: Apple (40%), Microsoft (35%), and Google (25%), covering the years 2020 through 2024.
  • fetch_prices() downloads all the closing prices, compute_returns() converts them to daily percentage changes, and compute_portfolio_returns() blends them into a single portfolio return per day.
  • portfolio_returns.describe() prints summary statistics (mean, standard deviation, min, max) so you can eyeball the data quality.
  • Save main.py.

Before you run this, what do you think the output will show? Will it print individual stock prices, or something else?

  • Run the tool by executing this command:
python main.py

You should see yfinance print a progress bar as it downloads stock data, followed by a line like Loaded 1256 days of portfolio returns. and a statistical summary with values like mean, std, min, and max. The exact day count depends on trading days in the date range, so your number may differ slightly.

What is the describe() output telling me?

The mean is the average daily return of your portfolio. The std (standard deviation) tells you how much the daily returns bounce around that average.

A negative min shows the worst single-day loss. This is exactly the kind of tail risk that Value-at-Risk tries to quantify.

Seeing an error when running main.py?

  • If you see ModuleNotFoundError, make sure your virtual environment is activated and that var_tool/__init__.py exists.
  • If the download hangs or times out, check your internet connection. yfinance needs to reach Yahoo Finance servers to pull data.
  • If you see a KeyError: 'Close', make sure you passed a list of tickers (["AAPL", "MSFT", "GOOGL"]) rather than a single string.

Still stuck? Help me debug why python main.py is failing.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and let's move on.

ⓧ I'd like to double check the full code

Here is the complete main.py file. Make sure yours matches exactly:

import numpy as np

from var_tool.data import fetch_prices, compute_returns, compute_portfolio_returns


def main():
    # Portfolio configuration
    tickers = ["AAPL", "MSFT", "GOOGL"]
    weights = [0.4, 0.35, 0.25]
    start_date = "2020-01-01"
    end_date = "2024-12-31"

    # Fetch data and compute returns
    print("Fetching price data...")
    prices = fetch_prices(tickers, start_date, end_date)
    returns = compute_returns(prices)
    portfolio_returns = compute_portfolio_returns(returns, weights)
    print(f"Loaded {len(portfolio_returns)} days of portfolio returns.\n")
    print(portfolio_returns.describe())


if __name__ == "__main__":
    main()
Commit your progress

Your data pipeline is working and printing real market data. Lock in this milestone with a commit.

  • Stage and commit all changes by running these commands:
git add .
git commit -m "Add data module: fetch prices and compute portfolio returns"
  • Confirm the commit was recorded by running:
git log --oneline

You should see two commits listed. Your latest commit with the message Add data module: fetch prices and compute portfolio returns appears at the top.

Commit says 'nothing to commit'?

  • Make sure you saved both var_tool/data.py and main.py before running git add .. Unsaved changes in VS Code are not on disk yet.
  • Run git status to check which files Git sees as changed.

Need more help with Git? Help me figure out why my git commit is not working.

Your data pipeline is live and printing real portfolio returns. Next up, you will use those returns to calculate Value-at-Risk three different ways and see how sensitive the risk estimate is to your modeling choices.

Implement Three VaR Methods

Your data pipeline is working. You can fetch real stock prices and see a statistical summary of your portfolio returns.

The next question is: how much money could this portfolio lose on a bad day? A single risk method gives you one answer, but different methods make different assumptions about how markets behave. Building three Value-at-Risk methods side by side reveals how sensitive the risk estimate is to your modeling choices. The catch is that at this point, you have no way to know which answer is right.

In this step, get ready to:
  • Create a Git feature branch and implement historical and parametric VaR functions.
  • Add a Monte Carlo simulation VaR method.
  • Wire all three methods into main.py and see the results printed side by side.
Create a feature branch and build the first two VaR functions

Professional developers keep experimental work off the main branch until it is ready. You will create a feature branch so your VaR code lives separately from the stable project base.

  • Create and switch to a new feature branch by running this command:
git checkout -b feature/var-calculator

You should see a message confirming you switched to the new branch.

What is a feature branch?

A feature branch is a copy of your codebase where you can build and test new functionality without affecting the main branch. Once you are happy with the feature, you merge it back in through a Pull Request.

Now it is time to build the VaR functions. Each function takes an array of portfolio returns and a confidence level, then estimates the worst expected loss.

What is Value-at-Risk?

Value-at-Risk (VaR) answers the question: "What is the most I could lose on 95% of trading days?" A 95% VaR of 2% means that on 95 out of 100 days, the portfolio loss should not exceed 2%.

Different methods estimate this threshold differently. Historical VaR looks at actual past losses. Parametric VaR assumes returns follow a bell curve. Monte Carlo VaR simulates thousands of possible outcomes. Each approach carries its own assumptions about how markets behave.

  • Open var_tool/var.py in VS Code and add the imports and the historical_var function by copying the following code:
import numpy as np
from scipy import stats


def historical_var(returns: np.ndarray, confidence_level: float = 0.95) -> float:
    """Compute VaR using the historical simulation method.

    Returns the VaR as a positive number representing potential loss.
    """
    alpha = 1.0 - confidence_level
    var = -np.quantile(returns, alpha)
    return float(var)

What does this code do?

  • numpy handles the math, and scipy.stats provides statistical functions you will use in the next method.
  • historical_var finds the actual loss at the 5th percentile of past returns. No assumptions about the shape of the distribution. The negative sign converts losses (negative returns) into a positive VaR number, which is the standard convention in risk management.
  • Below historical_var, add the parametric_var function:
def parametric_var(returns: np.ndarray, confidence_level: float = 0.95) -> float:
    """Compute VaR using the variance-covariance (parametric) method.

    Assumes returns are normally distributed.
    Returns the VaR as a positive number representing potential loss.
    """
    mu = np.mean(returns)
    sigma = np.std(returns, ddof=1)
    alpha = 1.0 - confidence_level
    z_score = stats.norm.ppf(alpha)
    var = -(mu + sigma * z_score)
    return float(var)

What does this code do?

  • parametric_var assumes returns follow a normal (bell curve) distribution. It calculates the mean and standard deviation, then uses stats.norm.ppf to find the z-score at the given alpha level.
  • stats.norm.ppf is the percent point function. It is the inverse of the cumulative distribution function. It tells you how many standard deviations from the mean correspond to a given tail probability.
  • ddof=1 in np.std uses sample standard deviation instead of population standard deviation. This is the correct choice when estimating from a sample of returns.
  • Save var_tool/var.py.
  • Confirm both functions are importable by running this command:
python -c "from var_tool.var import historical_var, parametric_var; print('Imports OK')"

You should see Imports OK printed with no errors.

Seeing an ImportError?

  • Make sure you saved var_tool/var.py before running the command.
  • Check that the file is inside the var_tool/ folder and that __init__.py exists in that folder.
  • Confirm your virtual environment is activated. You should see the environment name in your terminal prompt.

Help me debug my import error

Add the Monte Carlo simulation method

The first two methods use the actual data directly. Monte Carlo takes a different approach: it fits a normal distribution to the historical returns, then generates 100,000 simulated returns from that distribution and measures the VaR from the simulated data.

  • In var_tool/var.py, add the monte_carlo_var function below parametric_var:
def monte_carlo_var(
    returns: np.ndarray,
    confidence_level: float = 0.95,
    n_simulations: int = 100_000,
    seed: int = 42,
) -> float:
    """Compute VaR using Monte Carlo simulation.

    Simulates returns from a normal distribution fitted to historical data.
    Returns the VaR as a positive number representing potential loss.
    """
    mu = np.mean(returns)
    sigma = np.std(returns, ddof=1)
    rng = np.random.default_rng(seed)
    simulated_returns = rng.normal(mu, sigma, n_simulations)
    alpha = 1.0 - confidence_level
    var = -np.quantile(simulated_returns, alpha)
    return float(var)

What does this code do?

  • np.random.default_rng(seed) creates a reproducible random number generator. The fixed seed of 42 means you get the same simulated returns every time you run it.
  • rng.normal(mu, sigma, n_simulations) draws 100,000 random returns from a normal distribution with the same mean and standard deviation as the real data.
  • The VaR is then measured from this simulated dataset the same way as in the historical method. The advantage is that Monte Carlo can generate far more data points than you have in history, giving a smoother estimate of the tail.
  • Save var_tool/var.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved var_tool/var.py.

ⓧ I'd like to double check the full code

Your var_tool/var.py should contain the following sections in order. Scroll up and confirm each one is present:

  • The import numpy as np and from scipy import stats lines at the top.
  • The historical_var function (uses np.quantile).
  • The parametric_var function (uses stats.norm.ppf).
  • The monte_carlo_var function (uses np.random.default_rng).
Wire the VaR functions into main.py

The three VaR functions are ready. Now you need to call them from main.py so you can see the results.

  • In main.py, find this import line:
from var_tool.data import fetch_prices, compute_returns, compute_portfolio_returns
  • Add a new import directly below it, so the two import lines look like this:
from var_tool.data import fetch_prices, compute_returns, compute_portfolio_returns
from var_tool.var import historical_var, parametric_var, monte_carlo_var
  • Next, find this line inside the main() function:
    end_date = "2024-12-31"
  • Add a new line directly below it to set the confidence level:
    confidence_level = 0.95
  • Now find and delete this line near the bottom of the function:
    print(portfolio_returns.describe())
  • Replace it with the following VaR calculation code. Paste this below the print(f"Loaded... line:
    # Use full data for VaR calculation at this stage
    estimation_array = portfolio_returns.to_numpy()

    var_historical = historical_var(estimation_array, confidence_level)
    var_parametric = parametric_var(estimation_array, confidence_level)
    var_montecarlo = monte_carlo_var(estimation_array, confidence_level)

    print(f"=== VaR Results ({confidence_level:.0%} confidence) ===")
    print(f"Historical VaR:   {var_historical:.4%}")
    print(f"Parametric VaR:   {var_parametric:.4%}")
    print(f"Monte Carlo VaR:  {var_montecarlo:.4%}\n")

What does this code do?

  • portfolio_returns.to_numpy() converts the pandas Series into a plain NumPy array, which is what the VaR functions expect.
  • Each VaR function receives the same array of returns and the same 95% confidence level. The results show how each method estimates the worst-case daily loss.
  • The :.4% format displays each VaR as a percentage with four decimal places.
  • Save main.py.

Before you run the tool, take a moment to predict: do you think all three VaR values will be exactly the same, or different? Why?

  • Run the tool by executing this command:
python main.py

You should see three VaR values printed side by side. The exact numbers depend on market data, but you will see something like:

=== VaR Results (95% confidence) ===
Historical VaR:   2.15%
Parametric VaR:   1.98%
Monte Carlo VaR:  2.01%

The three numbers are close but not identical. Each method makes different assumptions, which is why the estimates differ. The question you cannot answer yet: which one is actually right?

Not seeing VaR output?

  • If you see an ImportError, check that the new import line in main.py matches the function names exactly: historical_var, parametric_var, monte_carlo_var.
  • If you see a NameError for confidence_level, make sure you added confidence_level = 0.95 inside the main() function, after the end_date line.
  • If you still see portfolio_returns.describe() output, you did not delete the old line. Remove it and save the file.

Help me debug my main.py output

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py.

ⓧ I'd like to double check the full code

Your main.py should contain the following sections in order. Scroll up and confirm each one is present:

  • The import numpy as np line, followed by the two from var_tool import lines.
  • The main() function with portfolio configuration including confidence_level = 0.95.
  • The fetch data section ending with the print(f"Loaded... line.
  • The VaR calculation block starting with estimation_array = portfolio_returns.to_numpy() and ending with the three print lines.
  • The if __name__ == "__main__": block at the bottom.
  • No print(portfolio_returns.describe()) line remaining.
  • Commit your work on the feature branch by running these commands:
git add .
git commit -m "Implement historical, parametric, and Monte Carlo VaR"

You should see a summary of the files committed on the feature/var-calculator branch.

You now have three risk estimates, but they are unvalidated predictions. In the next step, the Kupiec backtest will check each method against reality and reveal which ones hold up.

Backtest with the Kupiec Test

You now have three VaR values quantifying your portfolio's risk from three different angles.

But how do you know which one is actually correct? Those numbers look precise, but without validation they could be dangerously misleading. The Kupiec Proportion of Failures test is a statistical check that compares your model's predicted loss rate against what actually happened in the market. In this step, you'll split your data, then let the Kupiec test deliver a PASS or FAIL verdict for each VaR method.

In this step, get ready to:
  • Implement the Kupiec test function in backtest.py.
  • Update main.py to split data into estimation and backtest windows, then run the Kupiec test on each VaR method.
  • Run the tool and discover which models pass or fail under real market conditions.
Implement the Kupiec test function

The Kupiec test asks a simple question: did your VaR model predict the right number of losses? At 95% confidence, you expect roughly 5% of days to breach the VaR threshold. If the actual breach rate deviates too far, the model fails.

How does the Kupiec test work?

The test counts how many days actual losses exceeded the VaR threshold (called "exceptions"). It computes a likelihood-ratio statistic that measures the gap between the expected exception rate and the observed exception rate.

If that statistic exceeds the chi-squared critical value of 3.841 (at 5% significance), the model is rejected. Too many or too few exceptions both count as a failure.

  • In var_tool/backtest.py, add the imports, function signature, and exception-counting logic by copying this code:
import numpy as np
from scipy import stats


def kupiec_test(
    returns: np.ndarray, var_level: float, confidence_level: float = 0.95
) -> dict:
    """Run the Kupiec Proportion of Failures (POF) test.

    Tests whether the observed number of VaR exceptions matches
    the expected rate (1 - confidence_level).

    Args:
        returns: Array of portfolio returns.
        var_level: The VaR threshold (positive number).
        confidence_level: The VaR confidence level (e.g., 0.95).

    Returns:
        Dictionary with test results including pass/fail.
    """
    n_observations = len(returns)
    exceptions = int(np.sum(returns < -var_level))
    expected_rate = 1.0 - confidence_level
    observed_rate = exceptions / n_observations if n_observations > 0 else 0.0

What does this code do?

  • kupiec_test takes an array of portfolio returns, the VaR threshold, and the confidence level.
  • returns < -var_level flags every day where the actual loss exceeded the VaR prediction. These are the "exceptions" the test counts.
  • expected_rate is the theoretical rate (5% for 95% confidence). observed_rate is what actually happened in the data.
  • Save backtest.py. The next chunk completes the function with the statistical computation and return dictionary.
  • Directly below the observed_rate line you just added, copy the likelihood-ratio computation and return dictionary:
    if exceptions == 0:
        lr_statistic = -2.0 * n_observations * np.log(1.0 - expected_rate)
    elif exceptions == n_observations:
        lr_statistic = -2.0 * n_observations * np.log(expected_rate)
    else:
        lr_statistic = -2.0 * (
            (n_observations - exceptions) * np.log(1.0 - expected_rate)
            + exceptions * np.log(expected_rate)
            - (n_observations - exceptions) * np.log(1.0 - observed_rate)
            - exceptions * np.log(observed_rate)
        )

    p_value = 1.0 - stats.chi2.cdf(lr_statistic, df=1)
    critical_value = 3.841

    return {
        "n_observations": n_observations,
        "exceptions": exceptions,
        "expected_exceptions": round(expected_rate * n_observations, 1),
        "expected_rate": expected_rate,
        "observed_rate": round(observed_rate, 4),
        "lr_statistic": round(lr_statistic, 4),
        "p_value": round(p_value, 4),
        "critical_value": critical_value,
        "reject": lr_statistic > critical_value,
    }

What does this code do?

  • The if/elif/else block handles edge cases (zero exceptions, all exceptions) separately to avoid log(0) errors. The main else branch computes the full likelihood-ratio statistic.
  • stats.chi2.cdf converts the LR statistic into a p-value. If the statistic exceeds 3.841 (the chi-squared critical value at 5% significance), reject is set to True and the model fails.
  • The returned dictionary bundles everything: raw counts, rates, the test statistic, p-value, and a boolean verdict.
  • Save backtest.py.
  • Verify the module loads correctly by running this command:
python -c "from var_tool.backtest import kupiec_test; print('Import OK')"

You should see Import OK printed in the terminal.

Seeing a SyntaxError or IndentationError?

  • Make sure the second chunk is indented inside the kupiec_test function body. Every line should start with four spaces.
  • Check that there are no blank lines between the observed_rate line and the if exceptions == 0: line that would break the function scope.
  • Confirm that the closing parenthesis of the return dictionary has a matching opening brace.

Help me fix a SyntaxError in my kupiec_test function in backtest.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved backtest.py.

ⓧ I'd like to double check the full code

Your var_tool/backtest.py should contain the two blocks you added above, in this order:

  • Chunk 1: Imports (numpy, scipy.stats), the kupiec_test function signature with docstring, and the exception-counting logic (n_observations, exceptions, expected_rate, observed_rate).
  • Chunk 2: The if/elif/else block computing lr_statistic, the p_value and critical_value assignments, and the return dictionary.

Scroll up and confirm each block is present, in order.

Update main.py with data splitting and backtesting

Right now, main.py calculates VaR on the entire dataset. That means the model "sees" the same data it is being tested on. To run a fair backtest, you need to split the data: train on the first portion, then test against the rest.

  • In main.py, find this import line near the top of the file:
from var_tool.var import historical_var, parametric_var, monte_carlo_var
  • Add this new import line directly below it:
from var_tool.backtest import kupiec_test
  • Now find these two lines further down in the main() function:
    # Use full data for VaR calculation at this stage
    estimation_array = portfolio_returns.to_numpy()
  • Replace those two lines with the following data-split logic:
    # Split data: estimation window vs backtest window
    split_index = int(len(portfolio_returns) * 0.7)
    estimation_returns = portfolio_returns.iloc[:split_index]
    backtest_returns = portfolio_returns.iloc[split_index:]

    print(f"Estimation window: {len(estimation_returns)} days")
    print(f"Backtest window: {len(backtest_returns)} days\n")

    # Calculate VaR on estimation window
    estimation_array = estimation_returns.to_numpy()

Why split the data 70/30?

The first 70% of days become the estimation window where VaR is calculated. The remaining 30% become the backtest window where the Kupiec test checks the predictions against reality.

With roughly 1,250 total trading days, the 30% backtest window gives about 375 days. The Kupiec test needs at least 250 days for its chi-squared approximation to be reliable, so this split provides enough data for a meaningful test.

  • Save main.py and run it to confirm the data split works:
python main.py

You should now see two new lines printed between the loading message and the VaR results: one showing the estimation window size and one showing the backtest window size.

Seeing an ImportError for kupiec_test?

  • Check that var_tool/backtest.py is saved and contains the full kupiec_test function.
  • Confirm the import line reads from var_tool.backtest import kupiec_test with no typos.

Help me fix an ImportError when importing kupiec_test in my VaR backtesting project.

The VaR numbers may look slightly different from the previous step because they are now calculated on only 70% of the data instead of the full dataset. That is expected.

  • Now add the backtesting loop. In main.py, find the last VaR print line:
    print(f"Monte Carlo VaR:  {var_montecarlo:.4%}\n")
  • Copy the following backtest loop below that line:
    # Backtest each method with Kupiec test
    backtest_array = backtest_returns.to_numpy()

    print("=== Kupiec Backtest Results ===")
    methods = {
        "Historical": var_historical,
        "Parametric": var_parametric,
        "Monte Carlo": var_montecarlo,
    }

    for name, var_value in methods.items():
        result = kupiec_test(backtest_array, var_value, confidence_level)
        status = "FAIL (reject model)" if result["reject"] else "PASS (model OK)"
        print(f"\n{name} VaR:")
        print(f"  Exceptions: {result['exceptions']} / {result['n_observations']}")
        print(f"  Expected:   {result['expected_exceptions']}")
        print(f"  LR stat:    {result['lr_statistic']}")
        print(f"  p-value:    {result['p_value']}")
        print(f"  Result:     {status}")

What does this code do?

  • backtest_array converts the 30% backtest window into a numpy array for the Kupiec function.
  • The methods dictionary maps each method's name to its VaR value, so the loop can test all three with the same code.
  • For each method, kupiec_test returns a dictionary. The reject key determines whether the method gets a PASS or FAIL verdict.
  • Save main.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved main.py.

ⓧ I'd like to double check the full code

Your main.py should now contain these sections in order. Scroll up and confirm each one is present:

  • Imports: numpy, the three data functions, the three VaR functions, and kupiec_test.
  • Portfolio configuration (tickers, weights, dates, confidence level).
  • Data fetching and return computation.
  • Data split into estimation_returns (70%) and backtest_returns (30%) with print statements.
  • VaR calculation on estimation_array with all three methods plus print output.
  • Kupiec backtest loop running kupiec_test on each method against backtest_array.
Run the backtest and commit your work

Before you run the tool, think about what you expect. You have three VaR methods, each making predictions about how often losses should exceed the threshold. Do you think all three will pass the Kupiec test, or will one of them struggle?

  • Run the full tool by entering this command:
python main.py

You should see the VaR values for all three methods followed by the Kupiec backtest results. Each method shows its exception count, expected exceptions, LR statistic, p-value, and a clear PASS or FAIL verdict.

How do I read the backtest results?

  • Exceptions shows how many days actual losses exceeded the VaR threshold, out of the total backtest days.
  • Expected shows how many exceptions the model predicted (5% of backtest days for 95% confidence).
  • LR stat measures the gap between expected and observed. A value above 3.841 means the gap is too large to be random chance.
  • PASS means the model's predicted exception rate held up against reality. FAIL means the model's assumptions did not match what actually happened in the market.

Why might one method fail?

The parametric VaR method assumes returns follow a normal distribution. Real market returns have "fat tails", meaning extreme losses occur more often than a normal distribution predicts. The 2020-2024 date range includes the COVID crash, which produced exactly the kind of extreme moves that violate the normality assumption.

This is the whole point of backtesting. A VaR number is only useful if it actually predicts losses correctly.

Seeing unexpected results?

  • Your exact exception counts may vary slightly depending on the stock data returned by yfinance on the day you run this. The important thing is that PASS/FAIL verdicts appear for each method.
  • If all three methods show zero exceptions, your VaR threshold may be too loose. Check that confidence_level is set to 0.95 in main.py.

Help me understand the Kupiec backtest output from my VaR tool.

  • Commit your work on the feature branch by running these commands:
git add -A
git commit -m "Add Kupiec POF backtest and data split"

You should see a commit confirmation showing the changed files.

Commit rejected or nothing to commit?

  • Make sure both var_tool/backtest.py and main.py are saved before running git add.
  • Confirm you are on the feature/var-calculator branch by running git branch. The active branch should have an asterisk next to it.

Help me fix a Git commit issue in my VaR backtesting project.

Your VaR tool now does more than calculate risk. It proves whether each calculation is trustworthy. Next up, you'll write unit tests to lock down the correctness of your math, polish the project with a README, and push everything to GitHub with a Pull Request.

Write Unit Tests, README, and Open a Pull Request

Your VaR tool calculates risk three ways and backtests each model against real market data. But right now, nothing protects the code from breaking when you make changes.

In this step, you'll write unit tests that prove your math is correct, document the project with a README.md, and publish everything to GitHub with a professional Pull Request workflow.

In this step, get ready to:
  • Write pytest unit tests that validate each VaR method and the Kupiec backtest.
  • Create a README.md documenting install, usage, and methods.
  • Push to GitHub and open a Pull Request from the feature branch into main.
Write test imports, fixture, and the first test class

Unit tests let you run one command and know immediately if any of your calculations broke. Each test class groups related assertions about a single function.

  • In VS Code, open tests/test_var.py (currently empty) and paste the following code:
import numpy as np
import pytest

from var_tool.var import historical_var, parametric_var, monte_carlo_var
from var_tool.backtest import kupiec_test


@pytest.fixture
def sample_returns():
    """Generate a fixed set of normally distributed returns for testing."""
    rng = np.random.default_rng(123)
    return rng.normal(0.0005, 0.02, 500)


class TestHistoricalVar:
    def test_returns_positive_value(self, sample_returns):
        var = historical_var(sample_returns, 0.95)
        assert var > 0

    def test_higher_confidence_gives_higher_var(self, sample_returns):
        var_95 = historical_var(sample_returns, 0.95)
        var_99 = historical_var(sample_returns, 0.99)
        assert var_99 > var_95

What does this code do?

  • The @pytest.fixture creates a reusable set of 500 fake daily returns. Every test that accepts sample_returns as a parameter automatically receives this same dataset.
  • test_returns_positive_value checks that VaR comes back as a positive number, matching the standard sign convention where VaR represents potential loss.
  • test_higher_confidence_gives_higher_var confirms that asking for 99% confidence produces a larger VaR than 95%. If it didn't, the math would be wrong.
  • Save tests/test_var.py.
  • Run the first two tests by running this command:
pytest -v tests/test_var.py

You should see 2 tests collected, both showing PASSED in green.

Seeing import errors?

  • Make sure your virtual environment is still activated. You should see the environment name in your terminal prompt.
  • Confirm you're running the command from your project's root folder (the one containing var_tool/).
  • Check that the imports at the top of your test file match exactly: from var_tool.var import historical_var, parametric_var, monte_carlo_var.

Why are my pytest tests failing with import errors?

Add the parametric and Monte Carlo test classes
  • In tests/test_var.py, add the following two test classes below TestHistoricalVar:
class TestParametricVar:
    def test_returns_positive_value(self, sample_returns):
        var = parametric_var(sample_returns, 0.95)
        assert var > 0

    def test_higher_confidence_gives_higher_var(self, sample_returns):
        var_95 = parametric_var(sample_returns, 0.95)
        var_99 = parametric_var(sample_returns, 0.99)
        assert var_99 > var_95


class TestMonteCarloVar:
    def test_returns_positive_value(self, sample_returns):
        var = monte_carlo_var(sample_returns, 0.95)
        assert var > 0

    def test_reproducible_with_seed(self, sample_returns):
        var_1 = monte_carlo_var(sample_returns, 0.95, seed=42)
        var_2 = monte_carlo_var(sample_returns, 0.95, seed=42)
        assert var_1 == var_2

What does this code do?

  • TestParametricVar mirrors the historical tests. The same two properties (positive value, higher confidence = higher VaR) must hold regardless of the method.
  • test_reproducible_with_seed checks that Monte Carlo gives identical results when given the same random seed. Reproducibility matters when other people need to verify your numbers.
  • Save tests/test_var.py.
Add the Kupiec backtest test class

The final test class validates the backtest itself. You need to confirm that a model with the expected exception rate passes the test, and that the result dictionary contains all the required keys.

  • In tests/test_var.py, add the following class below TestMonteCarloVar:
class TestKupiecTest:
    def test_perfect_model_passes(self):
        """A model with exactly the expected exception rate should pass."""
        rng = np.random.default_rng(456)
        returns = rng.normal(0, 0.02, 1000)
        var = -np.quantile(returns, 0.05)
        result = kupiec_test(returns, var, 0.95)
        assert result["reject"] is False

    def test_result_contains_required_keys(self, sample_returns):
        var = historical_var(sample_returns, 0.95)
        result = kupiec_test(sample_returns, var, 0.95)
        required_keys = [
            "n_observations",
            "exceptions",
            "expected_exceptions",
            "lr_statistic",
            "p_value",
            "reject",
        ]
        for key in required_keys:
            assert key in result

What does this code do?

  • test_perfect_model_passes generates 1,000 synthetic returns, computes VaR directly from that data, then checks that the Kupiec test does not reject the model. A model tested on its own training data should pass.
  • test_result_contains_required_keys ensures the backtest dictionary always contains the keys your main.py depends on. If you ever rename a key in backtest.py, this test catches the mismatch immediately.
  • Save tests/test_var.py.

Before you run the full test suite, how many tests do you expect to see collected?

  • Run the full test suite by running this command:
pytest -v

You should see 8 tests collected, all showing PASSED. Each class and method name appears in the output so you can see exactly what was validated.

Seeing test failures?

  • If test_reproducible_with_seed fails, make sure you're passing seed=42 to both calls. Different seeds produce different results.
  • If test_perfect_model_passes fails, check that the seed is 456 and the sample size is 1000. These values are chosen so the model reliably passes.
  • Double check that your kupiec_test function in var_tool/backtest.py returns a dictionary with a reject key (not rejected or pass).

Help me debug my failing pytest tests in my VaR backtester project.

✔️ Awesome, I've got everything!

Great. Double check you've saved tests/test_var.py and all 8 tests pass.

ⓧ I'd like to double check the full code

Your tests/test_var.py file should contain the following chunks in order. Scroll up and confirm each one is present:

  • Imports (numpy, pytest, var_tool.var, var_tool.backtest) and the sample_returns fixture.
  • TestHistoricalVar class with test_returns_positive_value and test_higher_confidence_gives_higher_var.
  • TestParametricVar class with test_returns_positive_value and test_higher_confidence_gives_higher_var.
  • TestMonteCarloVar class with test_returns_positive_value and test_reproducible_with_seed.
  • TestKupiecTest class with test_perfect_model_passes and test_result_contains_required_keys.
Write the README and commit your work

A README is the first thing anyone sees when they visit your repository. It tells them what the project does, how to run it, and what the output means.

  • Create a new file called README.md in your project's root folder and paste the following content:
# Portfolio VaR Backtester

A command-line Python tool that downloads real stock data, computes Value-at-Risk using three industry methods, and validates predictions with the Kupiec statistical backtest.

## Install

```bash
pip install -r requirements.txt
```

## Run

```bash
python main.py
```

## Methods

- **Historical**: Empirical quantile of past losses
- **Parametric**: Normal distribution assumption
- **Monte Carlo**: 100,000 simulated return paths

## Kupiec Test

The Kupiec Proportion of Failures test checks whether the number of VaR exceptions matches the expected rate statistically.

What does the README cover?

  • The title and one-line description tell a visitor what this project is at a glance.
  • Install and Run sections give copy-pasteable commands so anyone can reproduce your results.
  • Methods and Kupiec Test sections explain the finance behind the code without requiring someone to read your source.
  • Save README.md.
  • Confirm the file exists by running:
ls

You should see README.md listed alongside main.py, requirements.txt, and your var_tool/ and tests/ directories.

  • Stage and commit your tests and README by running these commands:
git add tests/test_var.py README.md
git commit -m "Add unit tests and README"

You should see a commit confirmation message listing the 2 files changed.

Commit says nothing to commit?

  • Make sure you saved both tests/test_var.py and README.md before running git add.
  • Run git status to check which files Git sees as modified or untracked.

Help me figure out why git commit says nothing to commit.

Create a GitHub repository and open a Pull Request

Your code is committed locally. Now you'll push it to GitHub so others can see your work, and open a Pull Request to merge your feature branch into main.

  • Open your browser and go to github.com.
  • Click the + icon in the top right corner, then select New repository.
  • Enter portfolio-var-backtester as the repository name.
  • Leave all other settings as defaults (public, no README, no .gitignore).
  • Click Create repository.

Why no README or .gitignore on GitHub?

You already have a README.md in your local project. If GitHub creates one too, you'll get a merge conflict on your very first push.

GitHub shows a quick setup page with your repository URL. Copy the HTTPS URL.

  • Switch back to your terminal and connect your local repo to GitHub by running this command:
git remote add origin [[REPO_URL="https://github.com/your-username/portfolio-var-backtester.git"]]
  • Push all your local branches to GitHub by running this command:
git push --all origin

You should see output showing both main and feature/var-calculator pushed to origin.

Push rejected or authentication failed?

  • If you see an authentication error, GitHub requires a personal access token instead of a password. Go to Settings > Developer settings > Personal access tokens on GitHub to create one.
  • If the push is rejected, make sure the GitHub repository is completely empty (no README or license file created during setup).

Help me fix a git push error to GitHub.

  • Go back to your GitHub repository page in the browser and refresh.
  • You should see all your project files listed. Click the branch dropdown (it says main) and confirm feature/var-calculator appears as a second branch.

Now open a Pull Request to merge your feature work into the main branch.

  • Click the Compare & pull request button that appears in the yellow banner at the top of the repository page.
  • Confirm the base branch is main and the compare branch is feature/var-calculator.
  • Add a title like Add VaR calculator, backtest, and tests.
  • Click Create pull request.

What is a Pull Request?

A Pull Request is a proposal to merge code from one branch into another. It gives collaborators a chance to review, comment, and approve changes before they land in the main branch.

Even on solo projects, PRs create a clear record of what changed and why. Employers look for this workflow in portfolio projects.

  • On the Pull Request page, scroll down to confirm there are no merge conflicts.
  • Click Merge pull request.
  • Click Confirm merge.

You should see a purple "Merged" badge on the Pull Request page. Your feature branch code is now part of main.

Your VaR backtester is tested, documented, and published. Next up, an optional Secret Mission to add a visual chart of VaR breach patterns.

Secret mission

Visualize VaR Breaches

Your backtest results are just numbers in a terminal right now. Can you build a matplotlib visualization that plots every trading day's return as a bar chart, highlights breach days in red, and draws the VaR threshold as a dashed line? One glance at the chart should tell the story that the Kupiec test told in numbers.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses a free GitHub repository, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Local project folder on your Desktop (containing main.py, var_tool/, tests/, your virtual environment, and generated PNG charts).
  • GitHub repository (free, no ongoing cost).

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the tool soon.

  • Your project folder stays on your Desktop, ready to run whenever you need it.
  • Your GitHub repository remains live as a free portfolio piece at no cost.
  • The generated PNG charts (var_breaches_historical.png, var_breaches_parametric.png, var_breaches_montecarlo.png) stay in your project folder.

Pause - I'll come back to this later

Deactivate your virtual environment to free up your terminal session. Your files and GitHub repository stay intact for when you return.

  • If your virtual environment is still active (you see (venv) in your terminal prompt), deactivate it by running:
deactivate
  • When you come back, navigate to your project folder on the Desktop and reactivate the virtual environment.

Delete - I don't want to use this again

Remove all project resources from your machine and GitHub. This cannot be undone.

  • If your virtual environment is still active, deactivate it first by running:
deactivate
  • Delete your local project folder by running these commands. On macOS or Linux:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]
  • On Windows, run:
cd ~\Desktop
rmdir /s /q [[PROJECT_FOLDER="your project folder name"]]
  • To delete your GitHub repository, open the repository page in your browser.
  • Click the Settings tab near the top of the page.
  • Scroll to the bottom of the page to the Danger Zone section.
  • Click Delete this repository and follow the confirmation prompts.

Nice Work!

Nice Work!

Nice work! You just built a portfolio Value-at-Risk backtesting tool from scratch, computed risk three different ways, and statistically proved which models hold up against real market data.

You've learned how to:

  • Build a modular Python CLI tool that downloads real market data and computes portfolio Value-at-Risk using three industry methods: historical simulation, parametric VaR, and Monte Carlo simulation.
  • Validate quantitative models statistically using the Kupiec Proportion of Failures test, discovering which modeling assumptions hold up against real data and which ones break down.
  • Use a professional Git workflow with atomic commits, a feature branch, pytest unit tests, a polished README, and a Pull Request merged on GitHub.
  • Secret Mission: Created matplotlib visualizations showing VaR breach patterns over time, with breach days highlighted in red against the VaR threshold.

Ready to quiz yourself?